Skip to content

Commit 23c90e3

Browse files
committed
Replay observations and auto-approve pending calls on agent resume
- Add RunStore.observations_for_run to extract completed tool call results from a persisted run for replay into a new run's context - Add RunStore.pending_approval_for_run to detect last unresolved approval request, auto-approving it on resume - Pre-populate context observations from the prior run and count replayed calls against the tool call budget - Add --fresh-restart flag to skip replay and re-run from scratch
1 parent 9303610 commit 23c90e3

9 files changed

Lines changed: 288 additions & 28 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@
2828
<claude-mem-context>
2929
# Memory Context
3030

31-
# [teaagent] recent context, 2026-05-08 8:57am GMT+8
31+
# [teaagent] recent context, 2026-05-08 2:07pm GMT+8
3232

3333
Legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖️decision 🚨security_alert 🔐security_note
3434
Format: ID TIME TYPE TITLE
3535
Fetch details: get_observations([IDs]) | Search: mem-search skill
3636

37-
Stats: 18 obs (7,486t read) | 161,442t work | 95% savings
37+
Stats: 19 obs (8,027t read) | 170,927t work | 95% savings
3838

3939
### May 8, 2026
4040
22 12:31a 🟣 TeaAgent P0 Agent Harness — Initial Implementation
@@ -61,24 +61,7 @@ S7 Generate commit message for staged changes — TeaAgent intent clarification
6161
38 8:39a 🟣 Deterministic task clarification system added to TeaAgent
6262
39 8:46a 🟣 Memory Catalog added to teaagent
6363
S8 Add workspace memory catalog to teaagent — new MemoryCatalog feature with CLI, TUI, and agent prompt injection (May 8 at 8:46 AM)
64-
**Investigated**: Staged git diff across all modified files in /Users/teee/dev/teaagent, covering the full scope of the memory catalog implementation.
64+
40 8:57a 🟣 Deterministic task-based model routing added to teaagent
6565

66-
**Learned**: teaagent stores workspace memories as append-only JSONL at .teaagent/memory.jsonl. Search uses case-insensitive token intersection (all query tokens must appear in content+tags haystack). Agent runs automatically inject up to 5 matching memories into the model context dict under a "memories" key, which assemble_agent_prompt passes through alongside "observations" and "task_spec".
67-
68-
**Completed**: - teaagent/memory.py created: MemoryEntry (frozen dataclass, uuid4 hex id, content, tags, created_at) and MemoryCatalog (add/list/search/show, JSONL persistence), plus normalize_tags, memory_matches, memory_entries_to_prompt helpers
69-
- teaagent/chat_agent.py: ChatAgentConfig.memory_limit=5 added; run_chat_agent searches catalog at task start and wraps engine.decide with with_memories() injector
70-
- teaagent/cli.py: memory subcommand with add/list/search/show sub-subcommands, --root and --tag/--limit flags
71-
- teaagent/tui.py: memory command routed to _handle_memory(); HELP_TEXT updated
72-
- teaagent/prompt.py: memories field threaded through assemble_agent_prompt context
73-
- teaagent/__init__.py: MemoryCatalog and MemoryEntry exported in __all__
74-
- tests/test_memory.py: new file with catalog unit test and CLI integration test
75-
- tests/test_chat_agent.py: memory injection end-to-end test added
76-
- tests/test_tui.py: TUI memory command test added
77-
- docs/cli.md: Memory Catalog section with bash and TUI usage examples
78-
- Commit message generated for all staged changes
79-
80-
**Next Steps**: Session appears complete — commit message was the final deliverable. No further work indicated in the observed session.
81-
82-
83-
Access 161k tokens of past work via get_observations([IDs]) or mem-search skill.
66+
Access 171k tokens of past work via get_observations([IDs]) or mem-search skill.
8467
</claude-mem-context>

docs/cli.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,10 @@ Resume the original task from a persisted run id with optional new approval toke
452452
teaagent agent resume gpt <run_id> --root /path/to/repo --approve-call-id write-1
453453
```
454454

455+
By default, resume replays already-completed `tool_call_completed` observations into the new run's context so the model does not have to redo prior tool calls. If the original run paused with `pending_approval`, the pending `call_id` is auto-added to the approval list and reported back as `auto_approved_call_id` in the response payload.
456+
457+
Pass `--fresh-restart` to skip replay and re-run the original task from scratch.
458+
455459
Inside TUI:
456460

457461
```text

teaagent/chat_agent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def run_chat_agent(
9797
registry: Optional[ToolRegistry] = None,
9898
task_spec: Optional[str] = None,
9999
depth: int = 0,
100+
initial_observations: Optional[list[dict[str, Any]]] = None,
100101
) -> RunResult:
101102
tool_registry = registry or build_workspace_tool_registry(config.root)
102103
if config.enable_subagent and depth < config.max_subagent_depth:
@@ -135,6 +136,7 @@ def run_chat_agent(
135136
task=task,
136137
decide=lambda context: engine.decide(with_memories(context, memories)),
137138
run_id=run_id,
139+
initial_observations=initial_observations,
138140
)
139141
finally:
140142
if heartbeat is not None:

teaagent/cli.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,11 @@ def build_parser() -> argparse.ArgumentParser:
185185
agent_resume.add_argument("--subagent", action="store_true", help="Expose the 'subagent' tool.")
186186
agent_resume.add_argument("--max-subagent-depth", type=int, default=1, help="Maximum nested subagent depth.")
187187
agent_resume.add_argument("--heartbeat", type=float, default=0.0, help="Heartbeat interval seconds. 0 disables.")
188+
agent_resume.add_argument(
189+
"--fresh-restart",
190+
action="store_true",
191+
help="Re-run the original task from scratch instead of replaying observations from the prior run.",
192+
)
188193
agent_resume.set_defaults(func=agent_resume_command)
189194

190195
agent_status = agent_subparsers.add_parser("status", help="Show liveness status of a persisted run.")
@@ -387,10 +392,33 @@ def agent_resume_command(args: argparse.Namespace) -> int:
387392
except (FileNotFoundError, ValueError) as exc:
388393
print_json({"status": "error", "message": str(exc)})
389394
return 1
390-
return _execute_agent_task(args, original_task, resumed_from=args.run_id)
391-
392395

393-
def _execute_agent_task(args: argparse.Namespace, task: str, *, resumed_from: Optional[str] = None) -> int:
396+
initial_observations: list[dict[str, Any]] = []
397+
auto_approved: Optional[str] = None
398+
if not args.fresh_restart:
399+
initial_observations = store.observations_for_run(args.run_id)
400+
pending = store.pending_approval_for_run(args.run_id)
401+
if pending and pending["call_id"] not in args.approve_call_id:
402+
args.approve_call_id = list(args.approve_call_id) + [pending["call_id"]]
403+
auto_approved = pending["call_id"]
404+
405+
return _execute_agent_task(
406+
args,
407+
original_task,
408+
resumed_from=args.run_id,
409+
initial_observations=initial_observations,
410+
auto_approved_call_id=auto_approved,
411+
)
412+
413+
414+
def _execute_agent_task(
415+
args: argparse.Namespace,
416+
task: str,
417+
*,
418+
resumed_from: Optional[str] = None,
419+
initial_observations: Optional[list[dict[str, Any]]] = None,
420+
auto_approved_call_id: Optional[str] = None,
421+
) -> int:
394422
task_spec = None
395423
if args.clarify:
396424
clarification = clarify_task(task)
@@ -423,12 +451,17 @@ def _execute_agent_task(args: argparse.Namespace, task: str, *, resumed_from: Op
423451
),
424452
audit=audit,
425453
task_spec=task_spec,
454+
initial_observations=initial_observations,
426455
)
427456
store.logger_for_result(result, audit)
428457
payload = run_result_payload(result, routing=routing.to_dict() if routing else None)
429458
if resumed_from:
430459
payload["resumed_from"] = resumed_from
431460
payload["task"] = task
461+
if initial_observations:
462+
payload["replayed_observations"] = len(initial_observations)
463+
if auto_approved_call_id is not None:
464+
payload["auto_approved_call_id"] = auto_approved_call_id
432465
print_json(payload)
433466
return 0 if result.status == "completed" else 1
434467

teaagent/run_store.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,39 @@ def task_for_run(self, run_id: str) -> str:
7373
return task
7474
raise ValueError(f"run '{run_id}' has no run_started task")
7575

76+
def observations_for_run(self, run_id: str) -> list[dict[str, Any]]:
77+
observations: list[dict[str, Any]] = []
78+
for event in self.show_run(run_id):
79+
if event.get("event_type") != "tool_call_completed":
80+
continue
81+
payload = event.get("payload") or {}
82+
call_id = payload.get("call_id")
83+
tool_name = payload.get("tool_name")
84+
result = payload.get("result")
85+
if isinstance(call_id, str) and isinstance(tool_name, str) and isinstance(result, dict):
86+
observations.append({"call_id": call_id, "tool_name": tool_name, "result": result})
87+
return observations
88+
89+
def pending_approval_for_run(self, run_id: str) -> Optional[dict[str, Any]]:
90+
pending: Optional[dict[str, Any]] = None
91+
for event in self.show_run(run_id):
92+
event_type = event.get("event_type")
93+
payload = event.get("payload") or {}
94+
if event_type == "tool_call_pending_approval":
95+
call_id = payload.get("call_id")
96+
tool_name = payload.get("tool_name")
97+
arguments = payload.get("arguments")
98+
if isinstance(call_id, str) and isinstance(tool_name, str):
99+
pending = {
100+
"call_id": call_id,
101+
"tool_name": tool_name,
102+
"arguments": arguments if isinstance(arguments, dict) else {},
103+
}
104+
elif event_type in {"tool_call_approved", "tool_call_denied", "run_completed", "run_failed"}:
105+
if pending and payload.get("call_id") == pending.get("call_id"):
106+
pending = None
107+
return pending
108+
76109
def heartbeat_for_run(self, run_id: str) -> dict[str, Any]:
77110
events = self.show_run(run_id)
78111
last_heartbeat: Optional[dict[str, Any]] = None

teaagent/runner.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,26 @@ def _assert_cost_budget(self, cost_cents: float) -> None:
9191
if cost_cents > self.budget.max_estimated_cost_cents:
9292
raise BudgetExceededError("cost budget exceeded")
9393

94-
def run(self, *, task: str, decide: DecisionFn, run_id: Optional[str] = None) -> RunResult:
94+
def run(
95+
self,
96+
*,
97+
task: str,
98+
decide: DecisionFn,
99+
run_id: Optional[str] = None,
100+
initial_observations: Optional[list[dict[str, Any]]] = None,
101+
) -> RunResult:
95102
current_run_id = run_id or uuid4().hex
96-
context: dict[str, Any] = {"task": task, "observations": []}
103+
observations: list[dict[str, Any]] = list(initial_observations) if initial_observations else []
104+
context: dict[str, Any] = {"task": task, "observations": observations}
97105
iterations = 0
98-
tool_calls = 0
106+
tool_calls = len(observations)
99107
cost_cents = 0.0
100-
self.audit.record("run_started", current_run_id, task=task)
108+
self.audit.record(
109+
"run_started",
110+
current_run_id,
111+
task=task,
112+
replayed_observations=len(observations),
113+
)
101114

102115
while iterations < self.budget.max_iterations:
103116
iterations += 1

tests/test_chat_agent.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,74 @@ def test_cli_agent_resume_replays_original_task(self) -> None:
289289
self.assertEqual(payload["task"], "summarize repo")
290290
self.assertEqual(payload["final_answer"], "second")
291291

292+
def test_cli_agent_resume_replays_observations_and_auto_approves_pending(self) -> None:
293+
with tempfile.TemporaryDirectory() as tmp:
294+
(Path(tmp) / "note.txt").write_text("hello", encoding="utf-8")
295+
first_adapter = FakeAdapter(
296+
[
297+
'{"type":"tool","tool_name":"workspace_read_file","arguments":{"path":"note.txt"},"call_id":"read-1"}',
298+
'{"type":"tool","tool_name":"workspace_write_file","arguments":{"path":"out.txt","content":"hello!"},"call_id":"write-1"}',
299+
]
300+
)
301+
with (
302+
patch("teaagent.cli.create_llm_adapter", return_value=first_adapter),
303+
redirect_stdout(io.StringIO()) as first_out,
304+
):
305+
first_code = main(["agent", "run", "gpt", "process notes", "--root", tmp])
306+
first_payload = json.loads(first_out.getvalue())
307+
self.assertEqual(first_code, 1)
308+
self.assertEqual(first_payload["status"], "pending_approval")
309+
self.assertEqual(first_payload["approval"]["call_id"], "write-1")
310+
run_id = first_payload["run_id"]
311+
312+
resume_adapter = FakeAdapter(
313+
[
314+
'{"type":"tool","tool_name":"workspace_write_file","arguments":{"path":"out.txt","content":"hello!"},"call_id":"write-1"}',
315+
'{"type":"final","content":"wrote"}',
316+
]
317+
)
318+
with (
319+
patch("teaagent.cli.create_llm_adapter", return_value=resume_adapter),
320+
redirect_stdout(io.StringIO()) as resume_out,
321+
):
322+
resume_code = main(["agent", "resume", "gpt", run_id, "--root", tmp])
323+
resume_payload = json.loads(resume_out.getvalue())
324+
325+
self.assertEqual(resume_code, 0)
326+
self.assertEqual(resume_payload["status"], "completed")
327+
self.assertEqual(resume_payload["resumed_from"], run_id)
328+
self.assertEqual(resume_payload["replayed_observations"], 1)
329+
self.assertEqual(resume_payload["auto_approved_call_id"], "write-1")
330+
self.assertEqual((Path(tmp) / "out.txt").read_text(encoding="utf-8"), "hello!")
331+
332+
def test_cli_agent_resume_fresh_restart_skips_replay(self) -> None:
333+
with tempfile.TemporaryDirectory() as tmp:
334+
(Path(tmp) / "note.txt").write_text("hi", encoding="utf-8")
335+
first_adapter = FakeAdapter(
336+
[
337+
'{"type":"tool","tool_name":"workspace_read_file","arguments":{"path":"note.txt"},"call_id":"read-1"}',
338+
'{"type":"final","content":"first"}',
339+
]
340+
)
341+
with (
342+
patch("teaagent.cli.create_llm_adapter", return_value=first_adapter),
343+
redirect_stdout(io.StringIO()) as first_out,
344+
):
345+
main(["agent", "run", "gpt", "read note", "--root", tmp])
346+
run_id = json.loads(first_out.getvalue())["run_id"]
347+
348+
resume_adapter = FakeAdapter(['{"type":"final","content":"fresh"}'])
349+
with (
350+
patch("teaagent.cli.create_llm_adapter", return_value=resume_adapter),
351+
redirect_stdout(io.StringIO()) as resume_out,
352+
):
353+
main(["agent", "resume", "gpt", run_id, "--root", tmp, "--fresh-restart"])
354+
payload = json.loads(resume_out.getvalue())
355+
356+
self.assertEqual(payload["final_answer"], "fresh")
357+
self.assertNotIn("replayed_observations", payload)
358+
self.assertNotIn("auto_approved_call_id", payload)
359+
292360
def test_cli_agent_resume_unknown_run_id_returns_error(self) -> None:
293361
with tempfile.TemporaryDirectory() as tmp:
294362
output = io.StringIO()

tests/test_p0_harness.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,57 @@ def decide(context):
190190
self.assertNotIn("tool_call_started", [event.event_type for event in audit.events])
191191
self.assertEqual(audit.events[-1].payload["cost_cents"], 2.0)
192192

193+
def test_initial_observations_replayed_into_context(self) -> None:
194+
audit = AuditLogger()
195+
runner = AgentRunner(registry=build_registry(), audit=audit)
196+
seen: list[list[dict]] = []
197+
198+
def decide(context):
199+
seen.append(list(context["observations"]))
200+
return FinalAnswer(content="ok")
201+
202+
replayed = [
203+
{"call_id": "r1", "tool_name": "pilot_echo", "result": {"value": "earlier"}},
204+
{"call_id": "r2", "tool_name": "pilot_echo", "result": {"value": "later"}},
205+
]
206+
result = runner.run(
207+
task="resume",
208+
decide=decide,
209+
run_id="run-replay",
210+
initial_observations=replayed,
211+
)
212+
213+
self.assertEqual(result.status, "completed")
214+
self.assertEqual(result.tool_calls, 2)
215+
self.assertEqual(seen[0], replayed)
216+
run_started = next(e for e in audit.events if e.event_type == "run_started")
217+
self.assertEqual(run_started.payload["replayed_observations"], 2)
218+
219+
def test_initial_observations_count_against_tool_call_budget(self) -> None:
220+
audit = AuditLogger()
221+
runner = AgentRunner(
222+
registry=build_registry(),
223+
audit=audit,
224+
budget=RunBudget(max_iterations=3, max_tool_calls=2),
225+
)
226+
227+
def decide(_context):
228+
return ToolRequest(tool_name="pilot_echo", arguments={"value": "next"})
229+
230+
replayed = [
231+
{"call_id": "r1", "tool_name": "pilot_echo", "result": {"value": "a"}},
232+
{"call_id": "r2", "tool_name": "pilot_echo", "result": {"value": "b"}},
233+
]
234+
result = runner.run(
235+
task="budget-replay",
236+
decide=decide,
237+
run_id="run-budget-replay",
238+
initial_observations=replayed,
239+
)
240+
241+
self.assertEqual(result.status, "failed:model_logic")
242+
self.assertEqual(result.tool_calls, 2)
243+
193244
def test_cost_budget_blocks_final_after_decision_cost_update(self) -> None:
194245
audit = AuditLogger()
195246
runner = AgentRunner(

0 commit comments

Comments
 (0)