Skip to content

stream-json reports every tool as "name": "unknown" with empty input #281

Description

@konard

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.txthi), 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions