Summary
With --output-format stream-json, every tool_use event is emitted as "name": "unknown" with an empty "input": {} — regardless of which tool ran. The tool itself executes correctly with the right arguments; only the reported event is wrong.
This makes the stream-json output unusable for its main purpose: observing which tools an agent invoked. It affects any programmatic consumer (CI assertions, audit logs, replay harnesses).
Observed on @link-assistant/agent 0.25.0.
Reproduction
No model, network or API key needed — a ~60-line mock OpenAI-compatible server that streams exactly one tool call is enough.
mock-openai-server.mjs:
import { createServer } from 'node:http';
const port = Number(process.argv[2] || 8931);
const MODEL = 'formal-ai';
const sse = (payload) => `data: ${JSON.stringify(payload)}\n\n`;
const USAGE = { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 };
function toolCallStream() {
const base = { id: 'chatcmpl-probe', object: 'chat.completion.chunk', created: 0, model: MODEL };
return sse({
...base,
choices: [{
index: 0,
delta: {
role: 'assistant',
tool_calls: [{
index: 0,
id: 'call_probe_0',
type: 'function',
function: { name: 'write', arguments: JSON.stringify({ filePath: 'hi.txt', content: 'hi\n' }) },
}],
},
finish_reason: null,
}],
})
+ sse({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: USAGE })
+ 'data: [DONE]\n\n';
}
let completions = 0;
createServer((req, res) => {
req.on('data', () => {});
req.on('end', () => {
const url = req.url || '';
if (url.endsWith('/models')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ object: 'list', data: [{ id: MODEL, object: 'model', owned_by: 'mock' }] }));
return;
}
if (url.endsWith('/chat/completions')) {
completions += 1;
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
if (completions > 1) {
const base = { id: 'chatcmpl-done', object: 'chat.completion.chunk', created: 0, model: MODEL };
res.end(
sse({ ...base, choices: [{ index: 0, delta: { role: 'assistant', content: 'done' }, finish_reason: null }] })
+ sse({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: USAGE })
+ 'data: [DONE]\n\n',
);
} else {
res.end(toolCallStream());
}
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end('{}');
});
}).listen(port, '127.0.0.1', () => console.error(`[mock] listening on 127.0.0.1:${port}`));
Run it:
node mock-openai-server.mjs 8935 &
WORK=$(mktemp -d); git -C "$WORK" init -q
cfg='{"provider":{"formalai":{"name":"Mock","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"http://127.0.0.1:8935/api/openai/v1","apiKey":"local"},"models":{"formal-ai":{"name":"Mock"}}}},"model":"formalai/formal-ai"}'
cd "$WORK" && LINK_ASSISTANT_AGENT_CONFIG_CONTENT="$cfg" agent \
--model formalai/formal-ai --permission-mode auto \
--output-format stream-json --compact-json --disable-stdin \
--prompt 'Write hi to a file called hi.txt'
Expected
The tool_use event names the tool the server asked for:
{"type":"tool_use","name":"write","input":{"filePath":"hi.txt","content":"hi\n"}, ...}
Actual
{"type":"tool_use","timestamp":"...","session_id":"ses_...","name":"unknown","input":{},"tool_use_id":"prt_..."}
…and the file is written correctly (cat $WORK/hi.txt → hi), which is what localises this: the call is dispatched with the correct name and arguments, and only the stream-json event loses them.
Scope
- Reproduces both with and without
--compact-json — it is the stream-json event, not the compaction step.
- Not provider-specific: the wire format above is plain OpenAI streaming, with
name and arguments in delta.tool_calls[].function.
- Cross-checked against another harness on the same server and the same wire:
opencode (1.17.20 and 1.18.3) reports the same calls as write and bash, so the tool names are present in the stream being consumed.
Context
Found while building a two-harness E2E in link-assistant/formal-ai#715, where the CLI drove a real 7-round session that wrote its report correctly — every tool_use event in the captured stream reported unknown. Captured artifacts: docs/case-studies/issue-715/agent-cli-learning/.
Incidentally: omitting usage from the final chunk makes the CLI retry the round as a provider API error with exponential backoff (provider returned invalid usage data; retrying as provider API error), which is presumably #249 — the USAGE constant above is there to avoid it.
Summary
With
--output-format stream-json, everytool_useevent is emitted as"name": "unknown"with an empty"input": {}— regardless of which tool ran. The tool itself executes correctly with the right arguments; only the reported event is wrong.This makes the stream-json output unusable for its main purpose: observing which tools an agent invoked. It affects any programmatic consumer (CI assertions, audit logs, replay harnesses).
Observed on
@link-assistant/agent0.25.0.Reproduction
No model, network or API key needed — a ~60-line mock OpenAI-compatible server that streams exactly one tool call is enough.
mock-openai-server.mjs:Run it:
Expected
The
tool_useevent names the tool the server asked for:{"type":"tool_use","name":"write","input":{"filePath":"hi.txt","content":"hi\n"}, ...}Actual
{"type":"tool_use","timestamp":"...","session_id":"ses_...","name":"unknown","input":{},"tool_use_id":"prt_..."}…and the file is written correctly (
cat $WORK/hi.txt→hi), which is what localises this: the call is dispatched with the correct name and arguments, and only the stream-json event loses them.Scope
--compact-json— it is the stream-json event, not the compaction step.nameandargumentsindelta.tool_calls[].function.opencode(1.17.20 and 1.18.3) reports the same calls aswriteandbash, so the tool names are present in the stream being consumed.Context
Found while building a two-harness E2E in link-assistant/formal-ai#715, where the CLI drove a real 7-round session that wrote its report correctly — every tool_use event in the captured stream reported
unknown. Captured artifacts:docs/case-studies/issue-715/agent-cli-learning/.Incidentally: omitting
usagefrom the final chunk makes the CLI retry the round as a provider API error with exponential backoff (provider returned invalid usage data; retrying as provider API error), which is presumably #249 — theUSAGEconstant above is there to avoid it.