Skip to content

refactor(agent): give the agent loop's skill policy one owner - #3440

Merged
kwakayama merged 7 commits into
mainfrom
refactor/agent-loop-skill-state
Aug 7, 2026
Merged

refactor(agent): give the agent loop's skill policy one owner#3440
kwakayama merged 7 commits into
mainfrom
refactor/agent-loop-skill-state

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Gives the request-scoped active-skill policy one owner, so a skill-policy fix stops having to be hand-applied to two agent loops. Zero behaviour change.

The defect

src/agent/runtime/index.ts contains two agent loops — executeAgentLoop and executeAgentLoopStreaming. Both opened with a byte-identical 18-line skill-policy block: hydrateActiveSkillStateFromMessages, four activeSkill* let bindings, and the entire applySuccessfulSkillResult closure. A second duplicated transition (removeFormInputAfterSubmission + a hasSubmittedFormInput flag) appeared at 4 sites.

So a skill-policy bug fix had to be applied twice, with nothing enforcing the copies stayed in sync. That is the defect this PR removes.

The change

New src/agent/runtime/agent-loop-skill-state.tsthe single owner of the request-scoped active-skill policy for one agent loop attempt: which skill is active, what it permits, and how that changes when a skill activates or a form input is submitted.

Both loops now construct one AgentLoopSkillState and call methods on it. index.ts is net −46 lines. CONTEXT.md records the domain term.

The state is a class with mutable fields, deliberately: it is read ~23 times in one loop and ~26 in the other and is mutated in place, so an immutable snapshot would have changed semantics. There is no module-level or static mutable state — the original code's "Request-scoped skill policy (not class-level mutable state)" guarantee is preserved, and the tests prove two instances are independent behaviourally rather than asserting it in prose.

What this PR deliberately does NOT do

An earlier plan proposed unifying the two loops into one shared step function. I measured them and rejected that:

  • 630 and 644 lines, only 251 shared (~40%); largest divergence hunks are 206-vs-291 and 267-vs-219 lines.
  • They have different feature sets, not just different output shapes. executeAgentLoop supports toolReplacements, forking ~40 lines of config resolution with no streaming counterpart. Streaming carries controller/encoder/callbacks/textPartId plumbing with no non-streaming counterpart. Non-streaming wraps in withSpan("agent.execution_loop"); streaming does not. The parameter orders even differ — abortSignal and temperatureModelString are swapped.

Unifying those is a redesign reconciling two feature sets, not a refactor — and the riskiest logic (skill-policy edge cases) sits in the shared part. The duplication was the defect; the loops being separate was not.

Evidence

  • deno task test:unit: baseline 3801 passed / 27907 steps / 0 failed3802 / 27922 / 0 failed. Delta is exactly +1 file / +15 steps, matching the new test file 1:1 — zero regressions across the 66 co-located test files in src/agent/runtime/.
  • deno task verify:quick exit 0. deno check src/agent/index.ts clean.
  • Every call site was enumerated in the base file and mapped one-to-one to its replacement: 4 skill-result + 4 form-input sites in, 4 and 4 out (non-streaming 1+1, streaming 3+3). Zero surviving local bindings of any of the five identifiers — verified by grep.
  • docs/api-reference/veryfront/agent.md is a single line-pin shift, regenerated with Deno 2.7.7 matching CI's pin.

Reviewer note: why markFormInputSubmitted takes a boolean

It takes a precomputed submitted flag rather than recomputing it, because the caller's predicate is deliberately broader than the one applied internally, and the two genuinely disagree:

  • Callers use isSubmittedFormInputExecutionResult — recurses into nested objects to depth 3, ignores error markers.
  • removeFormInputAfterSubmission internally applies isSubmittedFormInputResult — shallow, only unwraps response/output, bails on an error marker.

They differ on inputs like { data: { submitted: true } }. So the flag cannot be recomputed inside the method without changing behaviour. The doc comment says so explicitly, to stop a future caller from "simplifying" it.

Known follow-ups (not blocking)

  • AgentLoopSkillState's fields are public and mutable, so the single-owner guarantee is convention-only today. No caller bypasses the transitions (verified). Private fields + getters would be the hardening if a third consumer appears.
  • hasSubmittedFormInputResult(messages) || runtimeContext?.[SUBMITTED_FORM_INPUT_CONTEXT_KEY] === true now lives in two places (hydrate and filterToolsAfterSubmittedFormInput). This PR reduced it from three copies to two; a one-line exported helper would finish it.
  • readApiErrorMessage (duplicated across two runtime clients) and isWithinJsonSizeLimit (4 copies) remain — separate PR.

Summary by CodeRabbit

  • New Features

    • Added consistent skill-state handling across standard and streaming agent interactions.
    • Skill activation, tool availability, delegation rules, and form submissions now remain synchronized throughout an interaction.
  • Documentation

    • Added documentation describing how skill state is maintained and updated.
    • Corrected an API reference source link.
  • Tests

    • Added comprehensive coverage for skill activation, policy updates, form submissions, invalid activations, and concurrent interactions.

Give the request-scoped active-skill policy one owner: a mutable
per-attempt class holding activeSkillId/Policy/ToolAvailability/
DelegationOverrides and hasSubmittedFormInput, hydrated from replay
history and mutated via applySuccessfulResult/markFormInputSubmitted.

Not yet wired into the loops (next commit); this only adds the module
and its tests, which cover hydration, both transitions, and that two
instances never share state.
Replace the duplicated 18-line skill-policy prologue and the
removeFormInputAfterSubmission + flag pair (4 call sites: 1 in
executeAgentLoop, 3 in executeAgentLoopStreaming) with reads/writes on
one AgentLoopSkillState instance per loop attempt. No behaviour change:
logic was moved verbatim into the class's hydrate/applySuccessfulResult/
markFormInputSubmitted methods.

Regenerated docs/api-reference/veryfront/agent.md — only a line-number
pin shift for AgentRuntime's source link.
Point future skill-policy work at AgentLoopSkillState as the single
owner, so the seam introduced in the prior two commits gets used
instead of re-duplicated.
The comment incorrectly implied the two agent loops differ in how they
determine the submitted predicate. In reality, both use the same helpers
but with different scopes: callers use a broader predicate
(isSubmittedFormInputExecutionResult) than the method applies internally
(isSubmittedFormInputResult). They can disagree, so the flag cannot be
safely recomputed inside the method without changing behavior.
@kojiwakayama
kojiwakayama requested a review from kwakayama as a code owner August 6, 2026 20:59
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 837905ec-9084-4772-ac87-68130e754394

📥 Commits

Reviewing files that changed from the base of the PR and between 5d216cb and 561b004.

📒 Files selected for processing (3)
  • CONTEXT.md
  • src/agent/runtime/agent-loop-skill-state.ts
  • src/agent/runtime/index.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/agent/runtime/agent-loop-skill-state.ts
  • CONTEXT.md
  • src/agent/runtime/index.ts

📝 Walkthrough

Walkthrough

Added AgentLoopSkillState as the request-scoped owner of active-skill policy. Both agent loops now hydrate, read, and update this state during tool execution.

Changes

Agent loop skill state

Layer / File(s) Summary
State contract and validation
src/agent/runtime/agent-loop-skill-state.ts, src/agent/runtime/agent-loop-skill-state.test.ts, CONTEXT.md, docs/api-reference/veryfront/agent.md
Added state hydration and mutation methods. Tests cover activation, form submission, invalid results, policy narrowing, and instance independence. Updated the AgentRuntime source link.
Generate loop integration
src/agent/runtime/index.ts
The generate loop now uses AgentLoopSkillState for initialization, policy checks, tool availability, delegation overrides, and tool-result updates.
Streaming loop integration
src/agent/runtime/index.ts
The streaming loop now uses AgentLoopSkillState for initialization, policy checks, delegation overrides, and tool-result updates.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AgentLoop
  participant AgentLoopSkillState
  participant ToolExecution
  AgentLoop->>AgentLoopSkillState: hydrate message history and runtime context
  AgentLoop->>AgentLoopSkillState: read active policy and tool state
  ToolExecution->>AgentLoopSkillState: apply successful result
  AgentLoopSkillState-->>AgentLoop: return updated policy and submission state
Loading

Suggested reviewers: kwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main refactor: centralizing the agent loop's skill policy in one owner.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/agent-loop-skill-state

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/agent/runtime/agent-loop-skill-state.ts (2)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use aliases for cross-module imports.

Both files import src/agent/types.ts through a parent-relative path. Use the internal alias for this cross-module import.

  • src/agent/runtime/agent-loop-skill-state.ts#L1-L1: replace ../types.ts with #veryfront/agent/types.ts.
  • src/agent/runtime/agent-loop-skill-state.test.ts#L5-L5: replace ../types.ts with #veryfront/agent/types.ts.

As per coding guidelines, use #veryfront/* for internal source imports. Based on learnings, use relative imports only for same-directory sibling files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/runtime/agent-loop-skill-state.ts` at line 1, Replace the
parent-relative Message imports in src/agent/runtime/agent-loop-skill-state.ts
(line 1) and src/agent/runtime/agent-loop-skill-state.test.ts (line 5) with the
`#veryfront/agent/types.ts` internal alias, keeping relative imports only for
same-directory siblings.

Sources: Coding guidelines, Learnings


16-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use direct public documentation.

Both changed documentation blocks use an em dash and indirect phrasing. Use short sentences and ASCII punctuation.

  • src/agent/runtime/agent-loop-skill-state.ts#L16-L21: state the caller construction and isolation requirements in direct sentences.
  • CONTEXT.md#L20-L29: state the loop ownership and mutation lifecycle in direct sentences.

As per coding guidelines, public TypeScript and Markdown copy must use direct, concise wording and ASCII punctuation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/runtime/agent-loop-skill-state.ts` around lines 16 - 21, Rewrite
the documentation in src/agent/runtime/agent-loop-skill-state.ts lines 16-21
using short, direct sentences with ASCII punctuation; state that callers
construct one state via hydrate per attempt and never share it across concurrent
runs. Rewrite the related documentation in CONTEXT.md lines 20-29 using direct
sentences with ASCII punctuation; state that each loop owns its state and
mutates it in place through the tool-result lifecycle.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/agent/runtime/index.ts`:
- Around line 1565-1572: Update the generatedToolResult handling branch before
its early return to apply the same successful-result transitions used by the
main path, including skillState.applySuccessfulResult and
skillState.markFormInputSubmitted with isSubmittedFormInputExecutionResult. Add
focused generate-loop coverage confirming generated load_skill activates its
policy and generated form_input sets hasSubmittedFormInput before the next
iteration.

---

Nitpick comments:
In `@src/agent/runtime/agent-loop-skill-state.ts`:
- Line 1: Replace the parent-relative Message imports in
src/agent/runtime/agent-loop-skill-state.ts (line 1) and
src/agent/runtime/agent-loop-skill-state.test.ts (line 5) with the
`#veryfront/agent/types.ts` internal alias, keeping relative imports only for
same-directory siblings.
- Around line 16-21: Rewrite the documentation in
src/agent/runtime/agent-loop-skill-state.ts lines 16-21 using short, direct
sentences with ASCII punctuation; state that callers construct one state via
hydrate per attempt and never share it across concurrent runs. Rewrite the
related documentation in CONTEXT.md lines 20-29 using direct sentences with
ASCII punctuation; state that each loop owns its state and mutates it in place
through the tool-result lifecycle.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7ec637d9-acc1-44f4-8cba-1f7dd893f7db

📥 Commits

Reviewing files that changed from the base of the PR and between 36c073f and 5d216cb.

📒 Files selected for processing (5)
  • CONTEXT.md
  • docs/api-reference/veryfront/agent.md
  • src/agent/runtime/agent-loop-skill-state.test.ts
  • src/agent/runtime/agent-loop-skill-state.ts
  • src/agent/runtime/index.ts

Comment thread src/agent/runtime/index.ts
The generate loop's generatedToolResult branch returned before the
skill-state transitions ran, so a successful generated load_skill result
did not activate its policy and a generated form_input result did not set
hasSubmittedFormInput. The streaming loop already applies both transitions
in its matchingResult and persistedResult paths; this restores the same
handling in the generate loop.

Also drop the em dash from the AgentLoopSkillState doc block and the
CONTEXT.md entry, and state both in direct sentences.
@kwakayama

Copy link
Copy Markdown
Contributor

Addressed the two nitpicks from the review body in 8e27194.

Direct public documentation — applied. The AgentLoopSkillState doc block and the CONTEXT.md entry no longer use an em dash and now read as short direct sentences. The one remaining em dash in CONTEXT.md is on line 61 and predates this branch, so I left it alone.

Aliases for cross-module imports — not applied. Every file in src/agent/runtime/ imports ../types.ts relatively: agent-delegation.ts, agent-runtime-step.ts, agent-markdown-adapter.ts, effective-agent-system.ts, and their tests. None uses #veryfront/agent/types.ts. Converting only the two new files would make them the sole exception in the directory, and converting all of them is a separate mechanical change, not part of a skill-state refactor. CONTRIBUTING.md contrasts the alias with a deep relative import (../../../../rendering/index.ts); a single-level ../types.ts sibling-of-parent import is what this directory already standardises on.

@kojiwakayama

Copy link
Copy Markdown
Contributor Author

@kentaro-wakayama heads up — I think our messages crossed. You pushed 8e27194 applying the CodeRabbit fix at 04:53; I posted an investigation reply on that thread at 05:01 that reaches the opposite conclusion. Flagging it rather than touching your commit, since this is your call.

The short version: the branch is unreachable for load_skill and form_input, so the transitions guard a state the provider cannot produce.

Three links in the chain:

  1. createRuntimeTool (runtime-tool-builder.ts:48) copies only type, description, inputSchemaexecute is dropped there. Its doc comment says "Tool execution remains owned by the agent runtime." So createRuntimeLoadSkillTool's execute never reaches the model layer.
  2. generateText here is #veryfront/runtime/runtime-bridge.ts:887, not an SDK agentic loop — one raw model call, no tool-execution machinery. buildDirectGenerateResult populates toolResults only from provider-returned tool-result content parts.
  3. Provider-native tools are exclusively web_fetch/web_search (anthropic) and web_search (openai). load_skill and form_input go out as ordinary client-side type:"function" tools, and providers only return result blocks for tools they ran server-side.

Confirmed by driving the real loop, not by reading. With real provider behaviour (model emits only a tool-call), a decoy tool present at step 0 is correctly gone at step 1 — the skill policy already activates today, via the locally-executed path. The generated-result branch is never entered for those tools. Reproducing the failure required hand-authoring a provider response that returns a tool-result block for a client-side function tool, which nothing emits.

That is also why the commit has no test: any test that fails without it must fabricate that response, and would lock in provider behaviour that cannot occur.

Your call on what to do, and either is defensible:

  • Keep it as defence-in-depth. Then it is worth a comment saying the path is currently unreachable and this is future-proofing, so the next reader does not conclude it is load-bearing. Also worth noting it makes refactor(agent): give the agent loop's skill policy one owner #3440 no longer a pure refactor — the PR body and its review both claim zero behaviour change, so that framing needs updating.
  • Drop it and instead record why the omission is correct. I had a comment ready for exactly that but discarded it when your commit landed, since it now contradicts the code.

One more thing the trace turned up, unrelated and untouched: buildDirectGenerateResult never sets providerExecuted, while the streamed builder does (runtime-bridge.ts:739, :750). So in doGenerate mode the providerExecuted === true tracing check in that branch can never fire. Probably worth its own issue.

Full evidence is in the resolved thread on index.ts.

Defer to the reachability trace on #3440: this branch is the generate-path
analogue of the streaming providerExecuted === true branch, which also omits
the transitions. Both handle only provider-executed results, so the
omission is deliberate rather than an oversight. The doc-style fix from
8e27194 stays.
@kwakayama

Copy link
Copy Markdown
Contributor

Reverted my change in 54da8f5. Your read is better than mine.

We landed on the same reachability facts independently: execute is dropped when the model-facing tool is built, provider-native tools are only web_search/web_fetch, so load_skill and form_input cannot appear in response.toolResults. I stopped there and treated the omission as an oversight worth closing defensively.

The part I got wrong is which branch this one mirrors. I paired it with the streaming loops matchingResultandpersistedResultpaths, which do apply the transitions. It actually pairs withif (tc.providerExecuted === true)`, which sets status, traces, pushes, and continues without touching skill state. Both branches handle only provider-executed results, so the parallel already holds and my patch broke it rather than restoring it.

src/agent/runtime/index.ts is now byte-identical to 5d216cb. The doc-style fix from 8e27194 stays: the AgentLoopSkillState block and the CONTEXT.md entry lost their em dashes. Leaving the explanatory comment at the branch to your commit so we do not write it twice.

Worth filing the buildDirectGenerateResult finding separately. If it never sets providerExecuted, the providerExecuted === true tracing check inside this same branch is dead in doGenerate mode, which means provider-executed tool calls go untraced on the generate path.

@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Thanks — and your correction on the pairing is the better insight, so I've built the comment around it rather than around my original framing.

You're right that this branch mirrors the streaming loop's if (tc.providerExecuted === true) branch, not matchingResult/persistedResult. I had described it loosely as "the generate-path analogue of the streaming providerExecuted branch" without spelling out that the locally-executed paths are the ones that legitimately apply the transitions. Naming both halves of the parallel is what stops the next person making the same call you did. Comment incoming on this branch, with that pairing stated explicitly.

Confirmed src/agent/runtime/index.ts is byte-identical to 5d216cbgit diff 5d216cbd5 HEAD --stat shows only CONTEXT.md and agent-loop-skill-state.ts, i.e. just your em-dash fix. So this is a pure refactor again and the PR description stands as written.

On the buildDirectGenerateResult finding — already filed, and it's worse than the tracing gap you predicted: #3446.

Your reasoning was right and the consequence goes one step further:

  1. Providers really do send it. anthropic-provider.ts:275-282 sets providerExecuted: true unconditionally on every web_search/web_fetch/MCP result block, typed as the literal true; openai-provider.ts:837-844 does the same for web_search_call.
  2. The direct path is the common one. shouldGenerateViaStream is only model._generateViaStream === true, and that flag is set in exactly one place — src/provider/veryfront-cloud/provider.ts:17. So veryfront-cloud diverts to the stream builder (correct all along), while direct-key Anthropic and OpenAI hit the broken builder. Default path for BYO-key users.
  3. Beyond the untraced calls you called out: persistGeneratedToolResult passes providerExecuted === true into createToolResultMessage, which defaults to false and drives a conditional spread. So a genuinely provider-executed result was persisted into conversation history with the marker absent entirely — wrong data, not just missing telemetry. Downstream code keys on it (chat/conversation.ts:376, and the completeness rule in finalized-message.test.ts:62).

Two lines to fix. The reason it survived is worth noting: the neighbouring uses the direct generate path for provider-native tools test feeds content with no providerExecuted field at all, so nothing could observe it being dropped. The red test in #3446 fails on exactly that missing field before the fix.

One adjacent thing flagged but not swept into #3446: tool-call parts drop the same field on the same path (runtime-bridge.ts:568-573), while both providers set it and the streamed path carries it. Whether toolCalls should carry it depends on whether any consumer reads it, which I haven't checked.

Add explanatory comment above the generatedToolResult branch in
executeAgentLoop (non-streaming path) to document why skill-state
transitions are intentionally absent. The branch handles provider-executed
tools only (web_search/web_fetch) and mirrors the streaming loop's
providerExecuted===true path, not the locally-executed paths. This
clarifies the distinction for reviewers and prevents re-raising the
resolved concern.
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.

2 participants