Skip to content

refactor(agent): clean-slate Agent loop — Message / AgentContext / single build_messages seam - #882

Open
moria97 wants to merge 33 commits into
featurefrom
personal/yfei/agent-loop
Open

refactor(agent): clean-slate Agent loop — Message / AgentContext / single build_messages seam#882
moria97 wants to merge 33 commits into
featurefrom
personal/yfei/agent-loop

Conversation

@moria97

@moria97 moria97 commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Why

The agent layer was hard to debug — what the model receives was assembled across four files, and react_agent.run_async was a ~240-line generator mixing message prep, streaming, an intent-nudge hack, tool dispatch, return_direct, and token-fitting. This replaces it with a flat agent built around message / context / loop.

What changed

New backend/agent/ layer (old react_agent.py / state.py / tool_utils.py deleted):

  • message.py — one Message type + from_thread() normalization
  • context.pyAgentContext, assembled once; the single inspectable object
  • tools.pyToolBox (schema / dispatch+retry / return_direct)
  • agent.pybuild_messages() (single model-input seam) + flat ~40-line run() loop
  • budgeting.py — token manager (renamed, internals unchanged)

chat.py/agent_service.py migrated to Agent/AgentContext; parse_attachment_tools returns attachment content as data instead of mutating the message.

Attachment bug structurally fixed: content flows parse_attachment_tools → AgentContext.attachments → build_messages → <attached_file>. Message is never mutated.

Preserved: budgeting, return_direct, guardrails, streaming (SSE unchanged), tracing. Dropped: intent-nudge buffering.

Testing

New tests/agent/ (31 tests) via a FakeLLM: text-stop, tool-call→answer, return_direct, max-steps, idle-timeout, thread normalization, attachment regression-lock, budget round-trip. Net −59 lines.

⚠️ Before merge

Unit/type-verified only — not yet run against the real PaiLlm. Smoke-test: upload a file → chat → confirm [agent] model input: shows the file body inside <attached_file> and the answer uses it.

🤖 Generated with Claude Code

moria97 and others added 14 commits June 25, 2026 22:51
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… wrapper

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add Agent.__init__, _stream_turn, run() to agent.py; create tests/agent/fake_llm.py
and tests/agent/test_agent_run.py (4 tests). Fix test_budgeting.py patch target from
memory.utils.get_tokenizer to agent.budgeting.get_tokenizer (ordering fragility exposed
by new module-level import).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…afe instance state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…attachments as data

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hread guard + cleanup

- Remove dead backend/agent/react_agent.py, state.py, tool_utils.py and
  their corresponding tests/agent/test_state.py, test_tool_utils.py
- Add empty-thread guard in chat.py after from_thread() to return early
  if msgs is empty (mirrors the existing empty-messages early-return)
- Retain append_text in agent_service.py (covered by test_agent_service.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pai_agent_wrapper read state.messages (old AgentState); the new
Agent.run(ctx) passes AgentContext positionally. Resolve the trace
source from either the legacy state= kwarg or the positional ctx, and
derive wire messages from history + current_turn so span INPUT_VALUE /
INPUT_MESSAGES are populated again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…otes, guard nil session close

- remove unused append_text + its obsolete test (logic now lives in render_current_turn)
- sanitize double-quotes in attachment filenames so they can't break the name="..." tag
- guard session.close() against None in the streaming finally (pre-existing nil deref)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
16099 8185 51% 0% 🟢

New Files

File Coverage Status
backend/agent/agent.py 91% 🟢
backend/agent/core/init.py 100% 🟢
backend/agent/core/events.py 100% 🟢
backend/agent/message.py 85% 🟢
backend/agent/tools/init.py 100% 🟢
backend/agent/tools/adapter.py 100% 🟢
backend/agent/tools/base.py 93% 🟢
backend/api/protocol/init.py 100% 🟢
backend/api/protocol/chat_serializer.py 86% 🟢
TOTAL 95% 🟢

Modified Files

File Coverage Status
backend/agent/context.py 100% 🟢
backend/api/v1/chat.py 55% 🟢
backend/common/llm/utils.py 83% 🟢
backend/extensions/trace/pai_agent_wrapper.py 35% 🟢
backend/service/agent/agent_service.py 45% 🟢
TOTAL 64% 🟢

updated for commit: 1700384 by action🐍

moria97 and others added 15 commits June 26, 2026 10:26
…orted

astream emits token usage on a dedicated empty-delta chunk
(TextChunk(usage=...), delta=""). The new _stream_turn's `elif chunk.delta`
guard dropped it, so the SSE serializer never accumulated usage and the
response reported no token counts. Forward chunks that carry usage even
when delta is empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clean core agent + dual public API: stateful /v1/responses (typed events,
hybrid Redis+SQL store) and stateless /v1/chat/completions shim, both over
one internal AgentEvent union. Decoupled from RAG/llamaindex; fresh schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per review: /v1/responses strictly conforms to OpenAI Responses event +
item schemas (incl. tool arguments and function_call_output outputs) with
no extra fields; PAI-RAG-only signals (tool progress, trace, latency,
stderr, citations) move to a separate opt-in pai.* extension channel.
Split compatibility bar by endpoint; SDK integration test asserts the
default responses stream is pure-OpenAI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bite-sized TDD tasks: AgentEvent union, Agent.run emits events,
ChatCompletionsSerializer (usage + invisible-timeout regression locks),
repoint /v1/chat/completions and retire legacy chunk serializers.
Phase 1 of the agent API protocol; ships the bug-class fix standalone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…/error)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…egression locks)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…drail (ordering + fail_fast)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…to _event_to_chunks (DRY)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ToolStarted: emit empty content chunk (no actions — arguments not yet available)
- ToolCompleted: emit actions chunk with {id, type, function:{name, arguments}}
  matching frontend's toolCall.function?.name / .function?.arguments reads
- ToolResult: emit observation as {tool:{id}, result, error}
  matching frontend's observation.tool.id / .result / .error reads
- Sync serializer: same shapes for actions[] and observations[] in the response dict
- trace_id: emit on terminal stop chunk via get_request_id() (request-scoped id)
  in both _event_to_chunks (pure stream path) and serialize_chat_stream_with_effects
  (effects path); also included in sync response dict
- Tests: add test_tool_chunks_match_frontend_shape, test_tool_actions_not_emitted_on_tool_started,
  test_sync_tool_actions_observation_match_frontend_shape, test_trace_id_emitted_on_stop_chunk

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…undary)

3 TDD tasks: clean Tool/ToolBox package, FunctionTool->Tool adapter,
wire the adapter at the agent_service boundary. RAG tool factories
unchanged; agent core decoupled from llama_index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
moria97 and others added 4 commits June 26, 2026 14:06
Convert backend/agent/tools.py into a package (tools/__init__.py +
tools/base.py). Introduce a plain `Tool` dataclass (name, description,
parameters schema, fn, return_direct) replacing FunctionTool. ToolBox
now dispatches, retries, and traces against Tool objects directly.
TDD: 5 new tests all pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… boundary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant