Files
james.maes 00f1075ea5 fix(agent): bundle assistant text with toolUse + document cache hint dormancy (#185)
Two related AgentCore tactical fixes from the 2026-05-12 audit.

#24 — Assistant text blocks alongside tool_use were dropped from message
history. Opus 4.7 commonly narrates ("Let me check the time for
you...") before calling a tool. On the next round-trip the appended
assistant message contained only the .toolUse block, so the model had
no record of the narration — a user follow-up like "what did you say
before checking the time?" would deny it.

Fix: introduce `assistantTextSinceLastFlush` parallel to
`assistantTextSoFar` (the latter stays cumulative for D-02 vision
routing). On every toolUseRequested, flush the per-flush buffer into a
.text(...) block bundled into the same assistant message as the
.toolUse, trim whitespace-only text to avoid Anthropic 400s on empty
text blocks, and reset the flush buffer so subsequent tool_uses in the
same round get only the text emitted between them. D-02 untouched.

#23 — CacheHints documentation flagged that the eligibility threshold
(4096 chars ≈ 1024 tokens) is structurally false on healthy boot
(preamble ~700 chars, empty initial context). Added a paragraph
explicitly documenting the dormancy, the intentional cost-acceptance
rationale, and the natural recovery once M-4 (v0.8) lands
searchFacts-driven hydration. No code change.

Tests: 3 new tests in AssistantTextWithToolUseTests cover text+toolUse
bundling, toolUse-alone (no spurious empty text block), and
whitespace-only-text rejection. Full AgentCore suite: 248 tests, 0
failures, 1 skipped.

Closes #23, #24
2026-05-14 06:53:35 -05:00
..

AgentCore

The agent loop, LLM provider protocol, and concrete providers. Four library products: shared types (AgentCore), the orchestrator (AgentOrchestrator), and two providers (AnthropicProvider, OllamaProvider).

Key public types

Type Purpose
LLMProvider (protocol) Provider-agnostic streaming contract — returns AsyncThrowingStream<LLMEvent, Error>
LLMEvent Stream event union — tokenDelta, thinkingDelta, toolUseRequested, usage, messageStop
LLMMessage Provider-neutral chat message (role + content blocks, including ImageBlock)
ToolSchema / ToolChoice Tool advertising + tool-choice discipline (auto, none, required)
ImageBlock Multimodal extension (Plan 07-05 / D-18); base64 + media type
ModelID Strong-typed model identifier (claude-opus-4-7, qwen2.5-coder:32b)
TurnID / TurnNonce Turn identity (TurnID on bus + replay; TurnNonce for SEC-06, never on bus)
CacheHints eligibleForSystemPrompt (≥1024 tokens), extended-cache-ttl beta header guard
UntrustedWrapper Prompt-injection isolation around tool results
ToolResultPacker 8 KB cap on model-facing tool result content (Opus 4.7 footgun)
BoundedAsyncChannel dropOldest-policy bounded channel for orch → replay events
AnthropicProvider Streaming SSE Opus 4.7 via Anthropic Messages API
OllamaProvider Native NDJSON /api/chat + OpenAI-compat /v1/chat/completions
AgentOrchestrator Actor — turn-lifecycle owner, tool dispatch, cap-recovery, retry
OrchestratorEvent One-consumer event stream — state changes, tokens, tool starts/ends

Depends on

Keychain, Replay, Vision, Logging, Config. External: swift-log.

Used by

App/AppDelegate, App/Voice/VoiceOrchestratorAdapter.swift, App/MCPBusGatewayAdapter.swift, packages/Memory, packages/Vision, packages/Harness, packages/DevOverlay, packages/MCP.

Key invariants / contracts

  • LLMProvider cancellation through Task.isCancelled — provider task closes the underlying transport on consumer cancel.
  • Tool-choice mandatory. Cap-recovery turns pass .none (Anthropic {"type":"none"}; Ollama drops tools array entirely).
  • turnNonce never on the bus or in any OrchestratorEvent (SEC-06).
  • Tool result content capped at 8 KB before model. Full bytes still go to replay.
  • extended-cache-ttl requires the beta header AND ttl: "1h" on cache_control. Without the header, 1h is silently ignored.
  • Cache hints gated on prompts ≥1024 tokens (CacheHints.eligibleForSystemPrompt).
  • Anthropic SSE: close on message_stop, not message_delta. Swallow ping. Route thinking_delta to its own case. Treat stop_reason: "refusal" first-class. Emit partial_tool_use_at_disconnect on mid-delta termination.
  • Ollama NDJSON: tool_calls arrive on the chunk preceding done: true — decoder reads tool_calls whenever seen, never gates on done.
  • One-consumer invariant on OrchestratorEvent stream — scripts/check-orchestrator-events-single-consumer.sh.

Tests

19 swift-testing + 72 XCTest. Includes SSE fixture corpus (packages/Harness/Sources/Harness/Corpus/SSEFixtureCorpus.swift), NDJSON fixture corpus, tool-cap-recovery scenarios, retry-budget tests. Live-network tests gated by JARVIS_REAL_MODELS=1.

Notable files

  • Sources/AgentCore/LLMProvider.swift — the core protocol + multimodal overload
  • Sources/AgentOrchestrator/AgentOrchestrator.swift — the turn-lifecycle actor
  • Sources/AnthropicProvider/SSEDecoder.swift — SSE parser with all the footgun cases
  • Sources/OllamaProvider/NDJSONDecoder.swift — /api/chat decoder; tool_calls-before-done
  • Sources/AgentCore/CacheHints.swift — Track A streamTruncated fix