Skip to content

epic: collapse chat route actions to ForwardToModel + tools (ADR-0026) #808

@eanzhao

Description

@eanzhao

Background

ADR-0026 records the architectural decision and full rationale:
docs/adr/0026-tool-first-chat-ingress.md

The status quo today carries two parallel routing dialects:

  1. The mature tool-calling backbone (ToolCallLoop + IAgentToolSource, 30+ live implementations including Scripting, Skills, NyxID, Lark) already drives "LLM decides → dispatcher executes → result flows back" — and /v1/responses already composes it on top of caller scope (nyxid-responses-direct.md §5).
  2. The remaining forward variants (ForwardToGAgent, ForwardToTeam, ForwardToWorkflow) on /v1/responses and /ws/voice are an alternative dispatch chain (ResponsesEndpoints.cs:779-927, AgentRunGAgent.cs:1108-1141, voice direct-bind to actor) that could be re-expressed as the LLM choosing a tool.

The cost is paid as: parallel adapter chain on /v1/responses and /ws/voice, TargetActorId override plumbing in NyxidChat, and /v1/messages returning HTTP 501 for the dialect it cannot host.

This is a CLAUDE.md §"统一投影链路 / 单一主干" violation surfaced. ADR-0026 collapses ChatRouteAction to two variants — Reject and ForwardToModel (with tool_set_ref + tool_choice_hint) — and reframes GAgent/Team/Workflow invocation as IAgentToolSource tools.

Use case validating the shape

A user calls /v1/responses directly through NyxID (no Aevatar UI session), says "push X to my Lark", and the LLM dispatches lark_message_send under the caller's NyxID bearer; the message lands in the caller's Lark account. The additive-tools pattern already in production for use_skill / ornn_search_skills shows this is mechanically supported; the orchestration tools simply join the same category.

What this issue is

The umbrella tracking the 5-stage execution plan in ADR-0026. Each stage ships independently; sub-issues are opened as each stage starts.

Stage 1 — Tool sources land, no router changes

  • Implement aevatar_invoke_gagent IAgentToolSource (payload schema = proto-derived strict mode)
  • Implement aevatar_invoke_team IAgentToolSource
  • Implement aevatar_start_workflow IAgentToolSource
  • Implement aevatar_observe_run IAgentToolSource
  • Implement aevatar_query_readmodel IAgentToolSource
  • Verify NyxID Lark relay outbound supports user-scoped push (D7 prerequisite Refactor/project namespace #1)
  • Verify / refactor Aevatar Lark outbound tool to propagate caller scope through AgentToolRequestContext (D7 prerequisite Feature/cqrs projection suite #2)
  • Integration test: /v1/responses calls aevatar_invoke_gagent, sub-run streams events back through SSE without any ForwardToGAgent involvement

Stage 2 — Policy carries tool injection

  • Add ForwardToModel.tool_set_ref (typed sub-message)
  • Add ForwardToModel.tool_choice_hint (typed sub-message)
  • ChatRouteResolver translates rules that previously emitted ForwardToGAgent/Team into ForwardToModel + tool_choice_hint
  • Introduce ChatRunActor (session-scoped) for SSE sessions; sub-run tracking in actor State, not middleware dictionary
  • Tool set registry: named tool compositions (workspace.default, lark.self_notify, voice.realtime, …)

Stage 3 — Deprecate legacy actions

  • Resolver emits deprecation warning header + structured log when policy still uses ForwardToGAgent/ForwardToTeam/ForwardToWorkflow
  • Policy migration tool: rewrites legacy rules to ForwardToModel + tool_choice_hint
  • Hold at least one release cycle

Stage 4 — Delete legacy code paths (breaking for stragglers)

  • Delete ResponsesEndpoints.cs:779-927 (legacy forward-to-actor branches)
  • Delete AgentRunGAgent.cs:1108-1141 (TargetActorId override)
  • Delete resolver branches for the three removed actions
  • Proto: mark ForwardToGAgent / ForwardToTeam / ForwardToWorkflow deprecated (kept on wire; tag reuse forbidden)
  • /v1/messages 501 fallback for these actions removed
  • CI guard: emission paths only produce ForwardToModel or Reject

Stage 5 — Voice convergence

  • Blocked by: VoicePresence.OpenAI GA migration — VoicePresence.OpenAI: migrate session.update to GA shape (blocks ADR-0026 Stage 5) #809
  • VoiceSessionActor implementation (session-scoped, owns Realtime WS + tool subscriptions)
  • /ws/voice no longer binds to actor at upgrade; resolver returns ForwardToModel(tool_set_ref=...)
  • OpenAI Realtime session.update declares resolved tool set
  • Backchannel conversation.item.create + response.create for async tool results
  • /ws/voice/{actorId} dev bypass unchanged (ADR-0024 D4)

Verification (when full epic is done)

  • /v1/responses policy + tool set drives GAgent invocation end-to-end; no ForwardToGAgent adapter touched
  • /v1/messages no longer returns HTTP 501 for any case the legacy policy would have routed
  • /ws/voice model can call aevatar_invoke_gagent mid-conversation; result lands as backchannel and is spoken
  • NyxID-direct caller hits /v1/responses, says "push X to my Lark", message arrives in caller's Lark
  • CI guards block emission of removed action variants

Out of scope

  • IActorDispatchPort / IActorRuntime / direct member invoke surface — internal actor-to-actor dispatch is untouched
  • /api/scopes/.../workflows workflow definition/editing endpoints
  • New readmodel design — aevatar_observe_run reads through existing projections
  • VoicePresence.OpenAI GA migration itself — tracked as prerequisite issue

References

  • ADR-0026 (this epic's source of truth)
  • ADR-0024 (chat route policy form, partially superseded §D5)
  • ADR-0025 (voice v1, superseded by D6)
  • ADR-0018 (per-user NyxID binding)
  • docs/canon/nyxid-responses-direct.md

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions