🚀 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:
kumarabhirup 2026-02-13 23:55:20 -08:00
parent fdfa5184ee
commit 9baed309d3
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
6 changed files with 178 additions and 105 deletions

View File

@ -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>
)}

View File

@ -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:

View File

@ -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) {

View File

@ -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

View File

@ -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",