feat(llm): resume the Claude Code session across a kill-chain phase (#149)

* feat(llm): resume the Claude Code session across a kill-chain phase

Every ReAct iteration flattens the growing transcript into a brand-new
prompt and spawns a fresh `claude -p` process. Within one task that is
a quadratic resend; across a mission it also means each spawn re-pays
Claude Code's own CLAUDE.md/skills/MCP bootstrap tax from scratch,
since T3MP3ST never told the CLI these calls belong to one session.

LocalAgentAdapter now tracks the Claude Code session id from the JSON
envelope and passes it back in with --resume on every later call, so
the CLI carries the accumulated transcript itself and the repeated
part is billed at cache-read pricing instead of resent. The session
is scoped to one kill-chain phase: TempestCommand drops it for every
operator right after advancePhase(), since local-agent operators are
spawned once for the whole mission and would otherwise carry one
session across all 7 phases. A stale or expired session id fails fast
(confirmed against the real CLI: nonzero exit, no JSON on stdout) and
localAgentChat retries once without --resume rather than failing the
task over it.

Verified against the real CLI: a resumed call recalled prior context
correctly and dropped from $0.151 to $0.008 (cache_read_input_tokens
absorbing the prior turn instead of cache_creation), and an invalid
--resume id triggered the fallback and still succeeded. Ran live
against an authorized target: the same session id held across all 4
recon tasks in one phase, then a newly spawned operator's first call
in the next phase carried no --resume, confirming both the resume and
the reset.

Full suite: 764/764, no regressions.

* fix(llm): send only the delta on a resumed Claude Code call

jmagly's PR #149 review caught it: LocalAgentAdapter.chat() built its
prompt from the full messages array unconditionally, so a resumed
call sent the whole growing transcript again on top of a session that
already had it. The resend the feature exists to remove was still
happening, and the resumed session's own history now grew on top of
it too. Existing tests only asserted that sessionId reached the CLI,
never what was actually in the prompt, so this passed green.

LocalAgentAdapter now tracks the exact messages array object and how
many of its entries were already sent. A later call against the SAME
array (AgentLoop growing one task's transcript via push) sends only
the new tail. A DIFFERENT array (a new task) still sends everything,
since the resumed session has never seen that task's content even
though the CLI session itself carries over. The stale-session
fallback in localAgentChat now takes a separate fallbackPrompt: a
fresh session has no history, so its retry gets the full transcript,
never the delta sized for the failed resumed attempt.

Verified against the real CLI with a synthetic multi-round exchange
(~750 tokens of padding added between each call): promptTokens went
19127 -> 19625 -> 20135 -> 20645, a flat ~510 tokens per round
regardless of the accumulating transcript, instead of resending or
compounding. Added the regression coverage the review asked for: a
same-array resumed call excludes the prior turn's content while
retaining the tool contract, a new-array call still gets the full
prompt, and the fallback retry receives fallbackPrompt rather than
the delta, checked both at the LocalAgentAdapter call-shape level and
against a real spawned CLI script.

Full suite: 769/769, no regressions.

* fix(test): import LLMMessage in local-agent-tool-calling.test.ts

Hosted CI caught it, not my local run: the previous commit used
LLMMessage[] type annotations in two new tests without importing the
type, so tsc failed with TS2304 while vitest itself stayed green
(esbuild strips type annotations without checking them). I ran
typecheck once before writing those tests and never reran it after,
so this went out uncaught.

Full suite: 769/769. npx tsc --noEmit: clean.

* fix(llm): isolate per-operator Claude sessions and narrow the fallback

A self-review after the last round turned up a more serious version of
what jmagly caught: TempestCommand built exactly one LLMBackbone and
handed the SAME instance to every operator's AgentLoop (and to each
OperatorAgent's own decompose-on-failure path). LocalAgentAdapter's
new session/delta state lived on that shared instance, so one
operator's Claude Code session could get resumed by a completely
different operator, with the second operator's unrelated task content
sent into the first operator's conversation. Every test for the
resume feature so far constructed independent LLMBackbone instances
per test, which is not how spawnOperator() actually wires things, so
nothing caught it.

spawnOperator() now builds a fresh LLMBackbone per operator from the
same config, used for both that operator's AgentLoop and its own
decompose fallback, so state stays consistent within one operator and
isolated from every other one. OperatorCell.spawnOperator() takes an
optional llm override to carry that through; existing callers that
don't pass one keep the cell's shared default.

Two narrower issues from the same review:

localAgentChat's stale-session fallback retried on ANY failure of a
resumed call, not just a confirmed stale session. A real network
error or auth failure would silently double latency on a retry that
could not fix it, and compounded with LLMBackbone's own retry ladder
into up to 6 CLI spawns for one failing turn. The fallback now only
fires for the exact empirically-confirmed stale-session error text
("No conversation found with session ID").

The character-estimate usage fallback (used only when the JSON
envelope fails to parse) estimated from the delta prompt instead of
the full transcript. A resumed call's real promptTokens reflects the
whole cached conversation, confirmed empirically in the last round
(19127 -> 19625 -> 20135 -> 20645), so estimating from the small delta
text instead undercounted the budget check by an order of magnitude
in that one edge case.

One finding from the review was deliberately left alone: resending
the tool contract on every delta call is real overhead, but jmagly's
review explicitly required the contract stay on every call ("retaining
... the tool contract") and the existing test already pins that. Cutting
it would contradict that requirement for a small, bounded savings.

New coverage: an isolation test that spawns two operators through a
real TempestCommand and confirms neither's LocalAgentAdapter nor its
session leaks into the other, a test proving a non-stale failure
propagates without a fallback retry, and a test proving the usage
estimate reflects the full transcript on a mid-session parse failure.

Full suite: 772/772, no regressions.

* fix(llm): reset the Claude session at every task boundary

jmagly's third review: switching from delta to full-prompt sending on
a new messages array wasn't enough, since LocalAgentAdapter.chat()
still passed the OLD claudeSessionId either way. A second task on the
same operator inherited the first task's persisted Claude session,
carrying its system prompt, target data, and tool output (including
anything attacker-controlled) across a boundary T3MP3ST already has a
single sanctioned channel for: PackBoard.

chat() now clears claudeSessionId whenever the incoming messages
array is not the one it last saw, before building the request. A
different array means AgentLoop.run() started a new task; the call
after that reset carries no --resume, so it opens a genuinely fresh
session instead of resuming the old one under a bigger prompt. Within
one task the array is the same object across iterations (AgentLoop
grows it via push), so resumption and the delta-only send from the
last round are untouched.

Rewrote the tests that asserted continuity across separate array
literals, since that shape doesn't happen in AgentLoop and was masking
this exact gap: they now grow one array via push to represent a real
task, matching the pattern already used for the delta tests. Added
the test jmagly asked for directly: one operator, two sequential
tasks, task 2 has no session id, both at the LocalAgentAdapter level
and through a real TempestCommand.spawnOperator().

Full suite: 773/773, no regressions.

---------

Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
This commit is contained in:
N3thunt3r69andJoseph Magly authored and GitHub committed 2026-08-23 17:12:27 -04:00
1 parent e05bdc4311
commit 52ff30de91
9 files changed
+516 -20

No files matched your search

+7
View File
@@ -89,6 +89,13 @@ Slow local agents can be given more room with `T3MP3ST_LOCAL_AGENT_TIMEOUT_MS`
for each CLI call, `T3MP3ST_TASK_TIMEOUT_MS` for mission tasks, and
`T3MP3ST_GENERAL_TIMEOUT_MS` for planning requests. Values are milliseconds.
Claude Code session reuse is disabled by default because a resumed coding-agent
session may retain provider-side context and ambient tool authority outside the
Arsenal receipt boundary. Operators who explicitly trust their local Claude
Code configuration as a separate execution authority may set
`T3MP3ST_TRUST_CLAUDE_SESSION=1`; reuse remains isolated to one task and the
choice must not be treated as an Arsenal-enforced sandbox.
Or run it **fully offline** on your own model — no key, no cloud. Defaults to Ollama; point it at any OpenAI-compatible server (LM Studio, vLLM, llama.cpp):
```bash