Skip to content

refactor: extract shared agent loop engine - #10

Merged
zhanghanduo merged 2 commits into
mainfrom
refactor/agent-loop-engine
Sep 1, 2026
Merged

zhanghanduo merged 2 commits into
mainfrom
refactor/agent-loop-engine

Conversation

@zhanghanduo

Copy link
Copy Markdown
Collaborator

Summary

  • move the complete logical-turn orchestration into AgentCore
  • share model profiles, reasoning/history normalization, and multi-format tool-call parsing
  • share parallel tool execution while keeping product deadline, context, metering, spill, and aggregate-budget behavior behind typed hooks
  • make endpoint registries host-configured and document the new boundary

Compatibility decisions

  • AgentLoopHooks owns session affinity, wall deadlines, provider-chain state, execution scope, cancellation cleanup, and spill-reference detection
  • ToolExecutionHooks owns timeout calculation/waits, per-call context, metering, result transforms, and batch budgeting
  • mixed native known/unknown calls are explicit via keep_unknown_native_companions; unknown-only batches always remain executable for corrective errors
  • dropped assistant tool-call IDs receive synthetic tool answers so the next provider request remains valid

Validation

  • uv run ruff check agent_core tests
  • uv run pyright agent_core
  • uv run pytest -q — 382 passed
  • uv build

@zhanghanduo
zhanghanduo force-pushed the refactor/agent-loop-engine branch 5 times, most recently from 6a5f713 to 81c32fd Compare September 1, 2026 02:30
@zhanghanduo
zhanghanduo force-pushed the refactor/agent-loop-engine branch from 81c32fd to 8266155 Compare September 1, 2026 02:34
…ooks

Review of the extracted agent-loop engine found the "every dropped
assistant tool_call_id gets an answer" invariant held on the normal
paths but not on observer-intervention paths, plus several hook
boundaries where a host mistake became an engine failure.

Correctness:

- tool_exec: an interrupt waiter resolving to False (no observer asked
  to interrupt) or raising cancelled the fan-in tool anyway and reported
  its collected work as "interrupted by a follow-up user message". Only a
  genuine request now cancels, and a tool that finished in the same
  wake-up keeps its result.
- agent_loop: skip_tool_execution, and continue_to_next_turn without
  pop_last_message, left the assistant tool_calls unanswered -> hard 400
  on the next request. Both now synthesise answers, inserted beneath the
  assistant message so an injected user message cannot split the pair.
- agent_loop: synthetic and executed tool_call_ids were numbered off
  different lists and collided once an observer skipped an earlier
  text-parsed call. Ids are now assigned once, before the split.
- tool_exec: a non-positive result cap (the usual "unlimited" sentinel)
  sliced from the end and silently dropped every result's tail.
- model_profile: a non-string `format` in the registry raised
  TypeError and aborted the whole load; a missing PyYAML disabled
  registry inference in total silence. Both are now per-row warnings /
  a loud error, and pyyaml is declared as a `registry` extra.
- model_profile: tag-format models that fill the typed reasoning channel
  AND leave inline <think> tags duplicated the reasoning in history and
  kept it there with thinking_in_history=False.
- agent_loop: the zero-filled usage fallback overwrote the previous
  turn's real token counts, so the context-overflow guard computed its
  estimate off 0 and never fired.
- agent_loop: _pop_last_assistant_turn was a silent no-op once a user
  message had been injected mid-turn, letting a rollback plus
  continue_to_next_turn replay the same turn until the attempt buffer
  ran out.

Boundaries:

- tool_exec: resolve_timeout, on_call, transform_batch and the result
  formatters ran outside the guarded region, so one raising metering
  hook killed the whole batch and the surrounding loop. They now fall
  back to core behavior with a warning.
- tool_exec: the fan-in tool names eligible for interruption are now a
  ToolExecutionHooks field instead of hardcoded product names.
- agent_loop: bind_session and sticky_session_enabled both owned session
  affinity and resolved silently; bind_session now explicitly wins, warns,
  and is typed LLMClient -> LLMClient rather than Any -> Any.
- agent_loop: HistoryPolicy.tool_result_max_chars was read by nobody. It
  now applies when the caller actually supplies a policy (never by
  dataclass default, which would newly truncate existing runs).
- model_profile: MessageNormalizer.to_history declared three parameters
  while the engine calls it with four, so the Protocol was unimplementable.

21 regression tests, each verified to fail before its fix. 404 passed,
ruff clean, pyright strict clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zhanghanduo
zhanghanduo merged commit 6be2f47 into main Sep 1, 2026
1 check passed
@zhanghanduo
zhanghanduo deleted the refactor/agent-loop-engine branch September 1, 2026 03:19
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