Skip to content

Four tool-calling run-fns accumulate on finish, and the cactus one is now pinned by a test #878

Description

@sroussey

Defect

.claude/CLAUDE.md states the rule: "Never accumulate output. A provider stream function yields text-delta / object-delta events and a finish carrying {} as Output. StreamingAiTask / TaskRunner does the accumulating. Do not 'helpfully' put accumulated data on the finish event."

Four run-fns emit text-deltas and then hand the accumulated text back on finish, so the accumulator receives it twice. Verified at 9a80450:

site payload on finish
providers/huggingface-transformers/src/ai/common/HFT_ToolCalling.ts:546 { text: cleanedText, toolCalls: validToolCalls }
providers/node-llama-cpp/src/ai/common/LlamaCpp_ToolCalling.ts:453 and :592 data: output (accumulated)
providers/llamacpp-server/src/ai/common/LlamaCppServer_ToolCalling.ts:152 { text: accumulatedText, toolCalls: finalToolCalls }
providers/cactus/src/ai/common/Cactus_ToolCallingCore.ts:88 { text: raw, toolCalls: validToolCalls }

This has been carried in the review series for six cycles. Two things make it worth a tracked issue now rather than another recommendation line.

1. The cactus site was rewritten this window and copied forward verbatim

1e00e1c / 6b22438 reduced Cactus_ToolCalling.ts to a two-line delegation and moved the body into the new Cactus_ToolCallingCore.ts. The function was retyped, re-documented, given a new engine-resolver parameter and eight new tests — and the finish payload was carried through unchanged. A full rewrite of the exact function is the moment the convention should have been applied.

2. It is now pinned by a passing assertion

packages/test/src/test/ai-provider-cactus/Cactus_ToolCalling.test.ts:131-136:

const { names, finishText } = await runToolCalling({});
expect(names).toEqual(["lookup_weather"]);
expect(finishText).toContain("lookup_weather");   // :136

finishText is read from ev.data.text on the finish event (:77). Fixing the convention now requires deleting a green test, which is the state where a documented rule stops being one.

Proposed fix

  1. Change all five emit points to emit({ type: "finish", data: {} as ToolCallingTaskOutput, usage }), keeping the object-delta on toolCalls that already precedes them. Update Cactus_ToolCalling.test.ts:136 to assert on the joined textDeltas instead of finishText — it already collects them (:65-67).
  2. Add the generic guard. There are now three working conformance assertions in packages/test/src/contract/ai-provider/assertions/ to sit beside (inferAdvertisesRegistered, usageNormalization, capabilityHonesty). The shape: over a recorded event list, assert that no finish payload field repeats content a text-delta or object-delta already emitted. Without it, the next provider added by copying one of these four inherits the bug.

Why it matters

The accumulator has to decide which copy wins. StreamProcessor already does the accumulating, so a finish that repeats it is at best redundant and at worst authoritative over the deltas the consumer actually streamed — and the four providers carrying it are the four local ones, where the delta stream is the only progress signal a user sees.

Found during the 2026-08-31 review (6th cycle). Snapshot: workglow-dev/prdanalysis/grades/2026-08-31/libs-providers.md.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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