🚀 RELEASE: v2026.2.10-1.14
Bump version and publish ironclaw@2026.2.10-1.14. Includes chain-of-thought, chat-message, active-runs, and agent-runner improvements. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
fdfa5184ee
commit
9baed309d3
@ -205,6 +205,23 @@ function faviconUrl(domain: string): string {
|
||||
return `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
|
||||
}
|
||||
|
||||
/* ─── Format tool args for display ─── */
|
||||
|
||||
/** Render tool arguments as a compact readable string. */
|
||||
function formatArgs(args: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (value === undefined || value === null) {continue;}
|
||||
const str =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value, null, 2);
|
||||
lines.push(`${key}: ${str}`);
|
||||
}
|
||||
const joined = lines.join("\n");
|
||||
return joined.length > 2000 ? joined.slice(0, 2000) + "\n..." : joined;
|
||||
}
|
||||
|
||||
/* ─── Classify tool steps ─── */
|
||||
|
||||
type StepKind =
|
||||
@ -311,9 +328,7 @@ function buildStepLabel(
|
||||
strVal("search_query") ??
|
||||
strVal("search") ??
|
||||
strVal("q");
|
||||
return q
|
||||
? `Searching for ${q.length > 60 ? q.slice(0, 60) + "..." : q}`
|
||||
: "Searching...";
|
||||
return q ? `Searching for ${q}` : "Searching...";
|
||||
}
|
||||
case "fetch": {
|
||||
const u =
|
||||
@ -325,7 +340,7 @@ function buildStepLabel(
|
||||
try {
|
||||
return `Fetching ${new URL(u).hostname}`;
|
||||
} catch {
|
||||
return `Fetching ${u.length > 50 ? u.slice(0, 50) + "..." : u}`;
|
||||
return `Fetching ${u}`;
|
||||
}
|
||||
}
|
||||
// Fallback: check output for the URL (web_fetch results include url/finalUrl)
|
||||
@ -336,7 +351,7 @@ function buildStepLabel(
|
||||
try {
|
||||
return `Fetched ${new URL(outUrl).hostname}`;
|
||||
} catch {
|
||||
return `Fetched ${outUrl.length > 50 ? outUrl.slice(0, 50) + "..." : outUrl}`;
|
||||
return `Fetched ${outUrl}`;
|
||||
}
|
||||
}
|
||||
return "Fetching page";
|
||||
@ -346,18 +361,14 @@ function buildStepLabel(
|
||||
if (p) {
|
||||
const short = p.split("/").pop() ?? p;
|
||||
return short.startsWith("http")
|
||||
? `Fetching ${short.slice(0, 50)}`
|
||||
? `Fetching ${short}`
|
||||
: `Reading ${short}`;
|
||||
}
|
||||
return "Reading file";
|
||||
}
|
||||
case "exec": {
|
||||
const cmd = strVal("command") ?? strVal("cmd");
|
||||
if (cmd) {
|
||||
const short =
|
||||
cmd.length > 60 ? cmd.slice(0, 60) + "..." : cmd;
|
||||
return `Running: ${short}`;
|
||||
}
|
||||
if (cmd) {return `Running: ${cmd}`;}
|
||||
return "Running command";
|
||||
}
|
||||
case "write": {
|
||||
@ -370,13 +381,30 @@ function buildStepLabel(
|
||||
}
|
||||
case "image":
|
||||
return strVal("description")
|
||||
? `Generating image: ${strVal("description")!.slice(0, 50)}`
|
||||
? `Generating image: ${strVal("description")!}`
|
||||
: "Generating image";
|
||||
default:
|
||||
return toolName
|
||||
default: {
|
||||
// For generic/unknown tools, build a descriptive label from args
|
||||
const name = toolName
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
if (args) {
|
||||
// Try common arg patterns for a meaningful summary
|
||||
const desc =
|
||||
strVal("command") ??
|
||||
strVal("cmd") ??
|
||||
strVal("query") ??
|
||||
strVal("path") ??
|
||||
strVal("url") ??
|
||||
strVal("message") ??
|
||||
strVal("description") ??
|
||||
strVal("input") ??
|
||||
strVal("text");
|
||||
if (desc) {return `${name}: ${desc}`;}
|
||||
}
|
||||
return name || "Tool";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1086,10 +1114,12 @@ function ToolStep({
|
||||
output?: Record<string, unknown>;
|
||||
errorText?: string;
|
||||
}) {
|
||||
const [showOutput, setShowOutput] = useState(false);
|
||||
const kind = classifyTool(toolName, args);
|
||||
// Show output by default for exec/command tools — these are the most
|
||||
// useful to see inline. Other tools default to collapsed.
|
||||
const [showOutput, setShowOutput] = useState(kind === "exec" || kind === "generic");
|
||||
// Auto-expand diffs for write tool steps
|
||||
const [showDiff, setShowDiff] = useState(true);
|
||||
const kind = classifyTool(toolName, args);
|
||||
const label = buildStepLabel(kind, toolName, args, output);
|
||||
const domains =
|
||||
kind === "search"
|
||||
@ -1149,7 +1179,7 @@ function ToolStep({
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-[13px] leading-snug"
|
||||
className="text-[13px] leading-snug flex items-start gap-2 flex-wrap"
|
||||
style={{
|
||||
color:
|
||||
status === "running"
|
||||
@ -1157,7 +1187,23 @@ function ToolStep({
|
||||
: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span className="break-all">{label}</span>
|
||||
{/* Exit code badge for exec tools */}
|
||||
{kind === "exec" && status === "done" && output?.exitCode !== undefined && (
|
||||
<span
|
||||
className="text-[11px] font-mono px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: output.exitCode === 0
|
||||
? "color-mix(in srgb, var(--color-success, #22c55e) 12%, transparent)"
|
||||
: "color-mix(in srgb, var(--color-error) 12%, transparent)",
|
||||
color: output.exitCode === 0
|
||||
? "var(--color-success, #22c55e)"
|
||||
: "var(--color-error)",
|
||||
}}
|
||||
>
|
||||
exit {String(output.exitCode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline diff for edit/write tool steps */}
|
||||
@ -1315,10 +1361,27 @@ function ToolStep({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output toggle — skip for media files, search, and diffs */}
|
||||
{/* Args summary — show for tools with no output/diff so they're never opaque */}
|
||||
{!outputText &&
|
||||
!diffText &&
|
||||
!isSingleMedia &&
|
||||
status === "done" &&
|
||||
args &&
|
||||
Object.keys(args).length > 0 && (
|
||||
<pre
|
||||
className="mt-1 text-[11px] font-mono rounded-lg px-2.5 py-1.5 whitespace-pre-wrap break-all max-h-48 overflow-y-auto leading-relaxed"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
>
|
||||
{formatArgs(args)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Output toggle — skip for media files and diffs only */}
|
||||
{outputText &&
|
||||
status === "done" &&
|
||||
kind !== "search" &&
|
||||
!isSingleMedia &&
|
||||
!diffText && (
|
||||
<div className="mt-1">
|
||||
@ -1338,15 +1401,15 @@ function ToolStep({
|
||||
</button>
|
||||
{showOutput && (
|
||||
<pre
|
||||
className="mt-1 text-[11px] font-mono rounded-lg px-2.5 py-2 overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto leading-relaxed"
|
||||
className="mt-1 text-[11px] font-mono rounded-lg px-2.5 py-2 overflow-x-auto whitespace-pre-wrap break-all max-h-96 overflow-y-auto leading-relaxed"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
>
|
||||
{outputText.length > 2000
|
||||
? outputText.slice(0, 2000) +
|
||||
"\n..."
|
||||
{outputText.length > 10000
|
||||
? outputText.slice(0, 10000) +
|
||||
"\n... (truncated)"
|
||||
: outputText}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
@ -153,10 +153,10 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
|
||||
errorText?: string;
|
||||
};
|
||||
// Persisted tool-invocation parts have no state field but
|
||||
// include result/errorText to indicate completion.
|
||||
// include result/output/errorText to indicate completion.
|
||||
const resolvedState =
|
||||
tp.state ??
|
||||
(tp.errorText ? "error" : "result" in tp ? "output-available" : "input-available");
|
||||
(tp.errorText ? "error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available");
|
||||
chain.push({
|
||||
kind: "tool",
|
||||
toolName:
|
||||
|
||||
@ -36,20 +36,23 @@ export type SseEvent = Record<string, unknown> & { type: string };
|
||||
/** Subscriber callback. Receives SSE events, or `null` when the run completes. */
|
||||
export type RunSubscriber = (event: SseEvent | null) => void;
|
||||
|
||||
type AccumulatedPart =
|
||||
| { type: "reasoning"; text: string }
|
||||
| {
|
||||
type: "tool-invocation";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: Record<string, unknown>;
|
||||
errorText?: string;
|
||||
}
|
||||
| { type: "text"; text: string };
|
||||
|
||||
type AccumulatedMessage = {
|
||||
id: string;
|
||||
role: "assistant";
|
||||
textParts: string[];
|
||||
reasoningParts: string[];
|
||||
toolCalls: Map<
|
||||
string,
|
||||
{
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
errorText?: string;
|
||||
}
|
||||
>;
|
||||
/** Ordered parts preserving the interleaving of reasoning, tools, and text. */
|
||||
parts: AccumulatedPart[];
|
||||
};
|
||||
|
||||
export type ActiveRun = {
|
||||
@ -177,9 +180,7 @@ export function startRun(params: {
|
||||
accumulated: {
|
||||
id: `assistant-${sessionId}-${Date.now()}`,
|
||||
role: "assistant",
|
||||
textParts: [],
|
||||
reasoningParts: [],
|
||||
toolCalls: new Map(),
|
||||
parts: [],
|
||||
},
|
||||
status: "running",
|
||||
startedAt: Date.now(),
|
||||
@ -293,6 +294,29 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
let agentErrorReported = false;
|
||||
const stderrChunks: string[] = [];
|
||||
|
||||
// ── Ordered accumulation tracking (preserves interleaving for persistence) ──
|
||||
let accTextIdx = -1;
|
||||
let accReasoningIdx = -1;
|
||||
const accToolMap = new Map<string, number>();
|
||||
|
||||
const accAppendReasoning = (delta: string) => {
|
||||
if (accReasoningIdx < 0) {
|
||||
run.accumulated.parts.push({ type: "reasoning", text: delta });
|
||||
accReasoningIdx = run.accumulated.parts.length - 1;
|
||||
} else {
|
||||
(run.accumulated.parts[accReasoningIdx] as { type: "reasoning"; text: string }).text += delta;
|
||||
}
|
||||
};
|
||||
|
||||
const accAppendText = (delta: string) => {
|
||||
if (accTextIdx < 0) {
|
||||
run.accumulated.parts.push({ type: "text", text: delta });
|
||||
accTextIdx = run.accumulated.parts.length - 1;
|
||||
} else {
|
||||
(run.accumulated.parts[accTextIdx] as { type: "text"; text: string }).text += delta;
|
||||
}
|
||||
};
|
||||
|
||||
/** Emit an SSE event: push to buffer + notify all subscribers. */
|
||||
const emit = (event: SseEvent) => {
|
||||
run.eventBuffer.push(event);
|
||||
@ -312,6 +336,7 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
reasoningStarted = false;
|
||||
statusReasoningActive = false;
|
||||
}
|
||||
accReasoningIdx = -1;
|
||||
};
|
||||
|
||||
const closeText = () => {
|
||||
@ -319,6 +344,7 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
emit({ type: "text-end", id: currentTextId });
|
||||
textStarted = false;
|
||||
}
|
||||
accTextIdx = -1;
|
||||
};
|
||||
|
||||
const openStatusReasoning = (label: string) => {
|
||||
@ -333,6 +359,7 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
});
|
||||
reasoningStarted = true;
|
||||
statusReasoningActive = true;
|
||||
accAppendReasoning(label);
|
||||
};
|
||||
|
||||
const emitError = (message: string) => {
|
||||
@ -342,7 +369,8 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
emit({ type: "text-start", id: tid });
|
||||
emit({ type: "text-delta", id: tid, delta: `[error] ${message}` });
|
||||
emit({ type: "text-end", id: tid });
|
||||
run.accumulated.textParts.push(`[error] ${message}`);
|
||||
accAppendText(`[error] ${message}`);
|
||||
accTextIdx = -1; // error text is self-contained
|
||||
everSentText = true;
|
||||
};
|
||||
|
||||
@ -400,7 +428,7 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
id: currentReasoningId,
|
||||
delta,
|
||||
});
|
||||
run.accumulated.reasoningParts.push(delta);
|
||||
accAppendReasoning(delta);
|
||||
}
|
||||
}
|
||||
|
||||
@ -419,7 +447,7 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
}
|
||||
everSentText = true;
|
||||
emit({ type: "text-delta", id: currentTextId, delta });
|
||||
run.accumulated.textParts.push(delta);
|
||||
accAppendText(delta);
|
||||
}
|
||||
// Media URLs
|
||||
const mediaUrls = ev.data?.mediaUrls;
|
||||
@ -442,7 +470,7 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
id: currentTextId,
|
||||
delta: md,
|
||||
});
|
||||
run.accumulated.textParts.push(md);
|
||||
accAppendText(md);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -485,10 +513,14 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
toolName,
|
||||
input: args,
|
||||
});
|
||||
run.accumulated.toolCalls.set(toolCallId, {
|
||||
// Accumulate tool start in ordered parts
|
||||
run.accumulated.parts.push({
|
||||
type: "tool-invocation",
|
||||
toolCallId,
|
||||
toolName,
|
||||
args,
|
||||
});
|
||||
accToolMap.set(toolCallId, run.accumulated.parts.length - 1);
|
||||
} else if (phase === "result") {
|
||||
const isError = ev.data?.isError === true;
|
||||
const result = extractToolResult(ev.data?.result);
|
||||
@ -502,8 +534,14 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
toolCallId,
|
||||
errorText,
|
||||
});
|
||||
const tc = run.accumulated.toolCalls.get(toolCallId);
|
||||
if (tc) {tc.errorText = errorText;}
|
||||
// Update the accumulated tool part
|
||||
const idx = accToolMap.get(toolCallId);
|
||||
if (idx !== undefined) {
|
||||
const part = run.accumulated.parts[idx];
|
||||
if (part.type === "tool-invocation") {
|
||||
part.errorText = errorText;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const output = buildToolOutput(result);
|
||||
emit({
|
||||
@ -511,8 +549,14 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
toolCallId,
|
||||
output,
|
||||
});
|
||||
const tc = run.accumulated.toolCalls.get(toolCallId);
|
||||
if (tc) {tc.output = output;}
|
||||
// Update the accumulated tool part
|
||||
const idx = accToolMap.get(toolCallId);
|
||||
if (idx !== undefined) {
|
||||
const part = run.accumulated.parts[idx];
|
||||
if (part.type === "tool-invocation") {
|
||||
part.result = output;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -528,11 +572,13 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
} else if (phase === "end") {
|
||||
if (statusReasoningActive) {
|
||||
if (ev.data?.willRetry === true) {
|
||||
const retryDelta = "\nRetrying with compacted context...";
|
||||
emit({
|
||||
type: "reasoning-delta",
|
||||
id: currentReasoningId,
|
||||
delta: "\nRetrying with compacted context...",
|
||||
delta: retryDelta,
|
||||
});
|
||||
accAppendReasoning(retryDelta);
|
||||
} else {
|
||||
closeReasoning();
|
||||
}
|
||||
@ -599,7 +645,7 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
: "[error] No response from agent.";
|
||||
emit({ type: "text-delta", id: tid, delta: errMsg });
|
||||
emit({ type: "text-end", id: tid });
|
||||
run.accumulated.textParts.push(errMsg);
|
||||
accAppendText(errMsg);
|
||||
} else {
|
||||
closeText();
|
||||
}
|
||||
@ -667,46 +713,24 @@ function flushPersistence(run: ActiveRun) {
|
||||
}
|
||||
run._lastPersistedAt = Date.now();
|
||||
|
||||
const text = run.accumulated.textParts.join("");
|
||||
if (
|
||||
!text &&
|
||||
run.accumulated.toolCalls.size === 0 &&
|
||||
run.accumulated.reasoningParts.length === 0
|
||||
) {
|
||||
const parts = run.accumulated.parts;
|
||||
if (parts.length === 0) {
|
||||
return; // Nothing to persist yet.
|
||||
}
|
||||
|
||||
// Build parts array matching the UIMessage format the frontend expects.
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (run.accumulated.reasoningParts.length > 0) {
|
||||
parts.push({
|
||||
type: "reasoning",
|
||||
text: run.accumulated.reasoningParts.join(""),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [toolCallId, tc] of run.accumulated.toolCalls) {
|
||||
parts.push({
|
||||
type: "tool-invocation",
|
||||
toolCallId,
|
||||
toolName: tc.toolName,
|
||||
args: tc.args,
|
||||
...(tc.output ? { result: tc.output } : {}),
|
||||
...(tc.errorText ? { errorText: tc.errorText } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (text) {
|
||||
parts.push({ type: "text", text });
|
||||
}
|
||||
// Build content text from text parts for the backwards-compatible
|
||||
// content field (used when parts are not available).
|
||||
const text = parts
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
|
||||
const isStillRunning = run.status === "running";
|
||||
const message: Record<string, unknown> = {
|
||||
id: run.accumulated.id,
|
||||
role: "assistant",
|
||||
content: text,
|
||||
parts,
|
||||
parts, // Ordered parts — preserves interleaving of reasoning, tools, text
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if (isStillRunning) {
|
||||
|
||||
@ -194,6 +194,9 @@ export function spawnAgentProcess(
|
||||
/**
|
||||
* Build a flat output object from the agent's tool result so the frontend
|
||||
* can render tool output text, exit codes, etc.
|
||||
*
|
||||
* Passes through ALL details fields — no whitelist filtering so the UI gets
|
||||
* the full picture (exit codes, file paths, search results, diffs, etc.).
|
||||
*/
|
||||
export function buildToolOutput(
|
||||
result?: ToolResult,
|
||||
@ -202,26 +205,9 @@ export function buildToolOutput(
|
||||
const out: Record<string, unknown> = {};
|
||||
if (result.text) {out.text = result.text;}
|
||||
if (result.details) {
|
||||
for (const key of [
|
||||
"exitCode",
|
||||
"status",
|
||||
"durationMs",
|
||||
"cwd",
|
||||
"error",
|
||||
"reason",
|
||||
// Web tool fields — pass through so the UI can show favicons / domains
|
||||
"url",
|
||||
"finalUrl",
|
||||
"targetUrl",
|
||||
"query",
|
||||
"results",
|
||||
"citations",
|
||||
// Edit tool fields — pass through so the UI can render inline diffs
|
||||
"diff",
|
||||
"firstChangedLine",
|
||||
]) {
|
||||
if (result.details[key] !== undefined)
|
||||
{out[key] = result.details[key];}
|
||||
// Pass through all details keys — don't filter so nothing is lost
|
||||
for (const [key, value] of Object.entries(result.details)) {
|
||||
if (value !== undefined) {out[key] = value;}
|
||||
}
|
||||
}
|
||||
// If we have details but no text, synthesize a text field from the JSON so
|
||||
@ -229,7 +215,7 @@ export function buildToolOutput(
|
||||
if (!out.text && result.details) {
|
||||
try {
|
||||
const json = JSON.stringify(result.details);
|
||||
if (json.length <= 12000) {
|
||||
if (json.length <= 50_000) {
|
||||
out.text = json;
|
||||
}
|
||||
} catch {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ironclaw",
|
||||
"version": "2026.2.10-1.13",
|
||||
"version": "2026.2.10-1.14",
|
||||
"description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user