Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion docs/a2a.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,95 @@ _, err = client.CancelTask(ctx, a2a.CancelTaskRequest{
})
```

## Visual Delegation Host Tools

The optional host-owned visual subagent delegation layer sits above the A2A client layer:

- `pkg/hosttools/a2adelegation` exposes a curated registry, `delegate_to_agent`
MCP tool server, delegation event bus, A2A event mapper, and `Delegator`.
- `pkg/bridges/subagentstream` overlays delegation events onto an existing
parent AG-UI stream as `CUSTOM` events named `subagent.*`.
- `pkg/bridges/sse.Options.SubagentBus` enables the overlay in the stock SSE
handler for AG-UI callers.

The model-facing tool input accepts registry keys only:

```json
{
"agent": "research",
"objective": "Find current A2A streaming behavior.",
"input": {
"prompt": "Summarize exact APIs and artifact behavior.",
"context": "We are implementing visual delegation."
},
"constraints": {
"timeout_seconds": 180,
"stream": true,
"max_artifacts": 10
}
}
```

`endpoint_url`, unknown top-level fields, and unknown nested input/constraint
fields are rejected. Host code owns the registry entry (`RemoteAgentSpec`), auth,
tenant, timeout policy, artifact limits, accepted output modes, and trusted
origins. `constraints.max_artifacts`, when supplied, limits only the final
`DelegationResult.Artifacts` returned to the parent model; live `subagent.*`
artifact events remain a UI side channel. The parent model receives only the
final structured `DelegationResult` JSON through the MCP tool result. Live remote
progress is published to the bus and rendered as AG-UI custom events:

```json
{
"type": "CUSTOM",
"name": "subagent.text.delta",
"value": {
"runId": "run-...",
"parentToolCallId": "tool-...",
"delegationId": "del-...",
"agentKey": "research",
"remoteProtocol": "a2a",
"remoteTaskId": "task-...",
"delta": "Searching official examples..."
}
}
```

`parentToolCallId` is included only when the host can supply the parent provider
tool-call ID, for example by setting `a2adelegation.MCPServerOptions.ParentToolCallID`
or by publishing `DelegationEvent` values with that field. The MCP JSON-RPC
request ID is not treated as the parent model tool-call ID. UI grouping should
tolerate this field being absent and fall back to `runId` + `delegationId`.

Runtime-created MCP sidecars can be injected into a run without asking the model
for run IDs, URLs, or credentials. A `RuntimeServiceManager` returns a
`RuntimeServiceRef` with metadata keys: `agentadaptor.mcp.enabled=true`,
`agentadaptor.mcp.key`, `agentadaptor.mcp.transport`, `agentadaptor.mcp.url`,
`agentadaptor.mcp.command`, `agentadaptor.mcp.args_json`,
`agentadaptor.mcp.env_json`, `agentadaptor.mcp.headers_json`,
`agentadaptor.mcp.bearer_token_env_var`, `agentadaptor.mcp.required`, and
`agentadaptor.mcp.required_reason`. If `agentadaptor.mcp.key` is omitted, the SDK
falls back to the runtime ref `Name`, then `ID`. If transport is omitted, it
defaults to `stdio` when a command is present and `http` otherwise. Non-stdio
servers use `RuntimeServiceRef.URL` when `agentadaptor.mcp.url` is omitted;
stdio servers use `RuntimeServiceRef.Command` when `agentadaptor.mcp.command` is
omitted. The SDK appends those servers after runtime ensure, before profile
materialization, so adapter MCP config and session fingerprints include the
per-run delegation endpoint.

Security boundary: concrete adapters stay unaware of A2A delegation; remote
protocol dumps are not appended to `RunResult.RawStreams`; remote artifacts are
reported as structured references/metadata unless host policy explicitly stores
and exposes content elsewhere.

## Non-Goals

Visual subagent delegation, A2A-to-local adapter routing, bridge-managed durable task persistence, and default push delivery infrastructure are outside this slice. Issue #5 tracks the visual subagent flow above these protocol primitives.
Automatic A2A-to-local adapter routing, bridge-managed durable task persistence,
default push delivery infrastructure, dynamic remote-agent discovery, built-in
tenant/auth policy, and production per-run MCP sidecar lifecycle remain outside
this slice.

Visual subagent delegation is an optional host-owned layer built from
`pkg/hosttools/a2adelegation`, `pkg/bridges/subagentstream`, and runtime-service
MCP injection. Core SDK execution remains protocol-agnostic and does not route to
remote A2A agents automatically.
17 changes: 17 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,23 @@ Context and injection:
- `WithSkills(refs...)`
- `WithMCP(cfg)`
- `WithProfileResources(resources)` — per-run profile desired state bundle.

Runtime service MCP injection: a `RuntimeServiceManager` may append run-scoped MCP
servers after `RuntimeServiceManager.Ensure` by returning `RuntimeServiceRef`
values whose metadata includes `agentadaptor.mcp.enabled=true`. This does not
require an explicit `WithMCP` option. Recognized metadata keys are
`agentadaptor.mcp.enabled`, `agentadaptor.mcp.key`,
`agentadaptor.mcp.transport`, `agentadaptor.mcp.url`,
`agentadaptor.mcp.command`, `agentadaptor.mcp.args_json`,
`agentadaptor.mcp.env_json`, `agentadaptor.mcp.headers_json`,
`agentadaptor.mcp.bearer_token_env_var`, `agentadaptor.mcp.required`, and
`agentadaptor.mcp.required_reason`. The SDK converts enabled refs to
`MCPServerSpec`s, appends them to the effective MCP config, validates them with
ordinary MCP capability checks, and includes them in `DriverRunRequest.MCP` plus
the profile/session fingerprint. Malformed JSON metadata and duplicate MCP keys
fail the run before adapter start. Other runtime metadata remains runtime-service
metadata; it is not promoted into MCP server configuration.

- `WithAgents(specs...)`
- `WithHooks(specs...)`
- `WithProfileConfig(patches...)`
Expand Down
49 changes: 42 additions & 7 deletions docs/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,46 @@ data: {"type":"RUN_FINISHED",...}
- `CORSAllowedOrigin`: 浏览器前端必须;空字符串不加 CORS 头
- `WriteTimeout`: 单帧写超时,默认 30s
- `RunOptions`: 应用到每次请求的 RunOption 列表(例如 `WithRunPolicy`)
- `DecodeRequest`: 自定义请求体解码
- `SubagentBus`: AG-UI 协议下把 host-published visual subagent delegation events 叠加为 `CUSTOM` 事件;Raw 协议不使用该 overlay

## 4. 配置
## 4. Visual subagent delegation overlay

### 4.1 per-binding 默认开
Hosts that expose a `delegate_to_agent` MCP tool can keep remote A2A progress out
of the parent model context while still rendering it live in the same AG-UI/SSE
stream. The delegation tool publishes `pkg/hosttools/a2adelegation.DelegationEvent`
values to a run-scoped bus; `pkg/bridges/subagentstream` merges that bus with the
parent run's AG-UI stream and forwards each remote event as an AG-UI `CUSTOM`
event such as `subagent.started`, `subagent.text.delta`, `subagent.artifact`, or
`subagent.finished`.

```go
bus := a2adelegation.NewEventBus(256)

mux.Handle("/v1/chat", sse.Handler(sdk, sse.Options{
Protocol: sse.AGUI,
SubagentBus: bus,
}))

// The MCP tool server for the same run calls delegator.Delegate(...), which
// returns only the final structured DelegationResult to the parent model while
// publishing live progress onto bus.SubscribeRun(ctx, handle.RunID()). When the
// run is no longer observable, call bus.ClearRun(handle.RunID()) (or equivalent
// host lifecycle cleanup) so replay and terminal-dedupe state do not grow without
// bound in long-lived services.
```

`SubagentBus` is honored by the stock SSE handler only when `Protocol: sse.AGUI`.
For Raw SSE, hosts that need the same side channel should call
`subagentstream.Wrap(...)` themselves and choose their own wire shape.

For lower-level hosts, use `subagentstream.WrapAGUI(ctx, handle, opts)` directly
instead of `agui.WrapWithContext`. Parent AG-UI lifecycle events are preserved;
remote subagent events remain a UI side channel and are not appended to
`RunResult.Output`, `RunResult.RawStreams`, or ordinary tool output.

## 5. 配置

### 5.1 per-binding 默认开

```go
sdk := agentadaptor.New(
Expand All @@ -169,7 +204,7 @@ handle2, _ := sdk.Start(ctx, "batch",

覆盖顺序:per-call `WithStreaming` / `WithoutStreaming` > per-binding `WithDefaultStreaming` > 默认 off。

### 4.2 背压
### 5.2 背压

```go
sdk := agentadaptor.New(
Expand All @@ -183,7 +218,7 @@ sdk := agentadaptor.New(

**宿主务必及时 drain `StreamEvents()`**,否则 Block 模式可能挂起,Drop 模式会丢事件。

## 5. 能力与降级
## 6. 能力与降级

每个 streaming-aware adapter 通过 `StreamAwareDriver.StreamCapability()` 声明自己能提供什么:

Expand All @@ -199,7 +234,7 @@ sdk := agentadaptor.New(
- `Reasoning=false` → 不发 `REASONING_*`
- HITL 事件默认映射成 AG-UI tool-call lifecycle,便于 CopilotKit 这类客户端渲染审批卡片;需要旧行为时使用 `agui.WithDecisionMode(agui.DecisionAsCustom)`

## 6. 常见问题
## 7. 常见问题

**Q: 开了 `WithStreaming` 但 `StreamEvents()` 一直没有 payload?**
A: 检查 adapter 是否实现了 `StreamAwareDriver`。未实现的 adapter 会走普通路径,`StreamEvents()` 返回 closed channel。
Expand All @@ -216,7 +251,7 @@ A: 安全。每个 `Start()` 创建独立 handle / sink / codex app-server 子
**Q: SSE 断连后 run 还在跑吗?**
A: 不在。SSE handler 在 client 断连时会调 `handle.Cancel()`,ctx cancellation 穿透到 codex 子进程。

## 7. AG-UI 前后端版本对齐
## 8. AG-UI 前后端版本对齐

Go 侧通过 `go.mod` 固定 `github.com/ag-ui-protocol/ag-ui/sdks/community/go`;`examples/streaming-chat-copilotkit/web` 通过 `package-lock.json` 固定 `@ag-ui/core`。两边不是同一个坐标系,升级任一侧时都可能出现 Zod/Go `Validate` 行为漂移。

Expand Down
40 changes: 40 additions & 0 deletions examples/streaming-chat-copilotkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,46 @@ Browser
| `AGENT_BACKEND_URL` | `http://localhost:8080/agent` | CopilotRuntime 转发的 AG-UI 端点 |
| `NEXT_PUBLIC_AGENT_BACKEND_BASE` | `http://localhost:8080` | 浏览器旁路请求 base URL |

## Visual subagent delegation hook

The backend can render remote A2A delegation progress in the same CopilotKit
AG-UI stream by wiring `sse.Options.SubagentBus` (or the lower-level
`subagentstream.WrapAGUI`) to the run session. A host-owned `delegate_to_agent`
MCP server publishes `a2adelegation.DelegationEvent` values while the tool call
is blocked on the remote task. CopilotKit receives those updates as AG-UI
`CUSTOM` events named `subagent.started`, `subagent.text.delta`,
`subagent.artifact`, `subagent.finished`, etc.; Claude/Codex/Cursor receives only
the final structured MCP result.

This example's current main path still starts one local parent CLI. To prove the
visual delegation product path, run a Codex A2A bridge separately (see
`examples/a2a-local` and `docs/a2a.md`), register its Agent Card in an
`a2adelegation.Registry`, start an HTTP `a2adelegation.MCPServer` for each parent
run, and expose that server through runtime-backed MCP metadata:

```text
agentadaptor.mcp.enabled=true
agentadaptor.mcp.key=a2a-delegation
agentadaptor.mcp.transport=http
agentadaptor.mcp.url=http://127.0.0.1:<port>/mcp
agentadaptor.mcp.bearer_token_env_var=A2A_DELEGATION_TOKEN
```

`agentadaptor.mcp.enabled=true` is the promotion switch. Other optional keys
include `agentadaptor.mcp.headers_json`, `agentadaptor.mcp.args_json`,
`agentadaptor.mcp.env_json`, `agentadaptor.mcp.required`, and
`agentadaptor.mcp.required_reason`. The SDK validates these as ordinary MCP
config; malformed JSON or duplicate MCP keys fail the run before the parent
adapter starts.

The important boundary for UI authors: render the `subagent.*` custom events as
a nested visual group, but do not add them to the chat transcript as parent model
messages. `subagent.*` event values may include `parentToolCallId` when the host
can correlate the delegation sidecar with a parent provider tool-use ID, but UI
code should not require it. Use `delegationId` as the stable nested group key,
with `runId` as the run scope. The parent transcript should contain the normal
tool call plus the concise final delegation result only.

## 相关文档

- [`docs/workstream-user-message-event.md`](../../docs/workstream-user-message-event.md)
Expand Down
30 changes: 22 additions & 8 deletions internal/adapterutil/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,18 +134,32 @@ func ResolvedTruthyEnv(bindings []agentadaptor.EnvBinding, name string) (bool, s
}

func RuntimeEnvBindings(bindings []agentadaptor.EnvBinding, payload agentadaptor.RuntimePayload) ([]agentadaptor.EnvBinding, error) {
if len(payload.Ensured) == 0 {
return append([]agentadaptor.EnvBinding(nil), bindings...), nil
}
raw, err := json.Marshal(payload.Ensured)
if err != nil {
return nil, err
}
out := append([]agentadaptor.EnvBinding(nil), bindings...)
out = append(out, agentadaptor.EnvBinding{Name: "PAPERCLIP_RUNTIME_SERVICES_JSON", Value: string(raw)})
out = append(out, payload.SecretEnv...)
if len(payload.Ensured) > 0 {
raw, err := json.Marshal(runtimeServiceRefsForEnv(payload.Ensured))
if err != nil {
return nil, err
}
out = append(out, agentadaptor.EnvBinding{Name: "PAPERCLIP_RUNTIME_SERVICES_JSON", Value: string(raw)})
}
return out, nil
}

func runtimeServiceRefsForEnv(refs []agentadaptor.RuntimeServiceRef) []agentadaptor.RuntimeServiceRef {
if len(refs) == 0 {
return nil
}
out := make([]agentadaptor.RuntimeServiceRef, 0, len(refs))
for _, ref := range refs {
clean := ref
clean.SecretEnv = nil
clean.Metadata = cloneStringMap(ref.Metadata)
out = append(out, clean)
}
return out
}

func RuntimePromptPrefix(payload agentadaptor.RuntimePayload) string {
if len(payload.Ensured) == 0 {
return ""
Expand Down
66 changes: 66 additions & 0 deletions internal/adapterutil/helpers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package adapterutil

import (
"strings"
"testing"

agentadaptor "github.com/agent-dance/agent-adaptor"
Expand All @@ -26,3 +27,68 @@ func TestResolvedTruthyEnvRecognizesTrueValues(t *testing.T) {
t.Fatalf("unexpected truthy env result: %v %q", ok, source)
}
}

func TestRuntimeEnvBindingsInjectsSecretEnvWithoutLeakingRuntimeJSON(t *testing.T) {
const secret = "sk-runtime-secret"

env, err := RuntimeEnvBindings(
[]agentadaptor.EnvBinding{{Name: "EXISTING", Value: "1"}},
agentadaptor.RuntimePayload{
SecretEnv: []agentadaptor.EnvBinding{{Name: "DELEGATION_TOKEN", Value: secret}},
Ensured: []agentadaptor.RuntimeServiceRef{{
ID: "svc-delegation",
Name: "delegation",
URL: "http://127.0.0.1:43127/mcp",
Status: agentadaptor.RuntimeServiceRunning,
Metadata: map[string]string{
"agentadaptor.mcp.enabled": "true",
"agentadaptor.mcp.bearer_token_env_var": "DELEGATION_TOKEN",
},
SecretEnv: []agentadaptor.EnvBinding{{Name: "DELEGATION_TOKEN", Value: secret}},
}},
},
)
if err != nil {
t.Fatalf("runtime env bindings: %v", err)
}
if got := lastEnvValue(env, "DELEGATION_TOKEN"); got != secret {
t.Fatalf("expected runtime secret env to reach subprocess env, got %q in %#v", got, env)
}
runtimeJSON := lastEnvValue(env, "PAPERCLIP_RUNTIME_SERVICES_JSON")
if runtimeJSON == "" {
t.Fatalf("expected runtime services JSON in env: %#v", env)
}
if strings.Contains(runtimeJSON, secret) {
t.Fatalf("runtime services JSON leaked secret: %s", runtimeJSON)
}
if !strings.Contains(runtimeJSON, "DELEGATION_TOKEN") {
t.Fatalf("runtime services JSON should retain bearer env var reference, got %s", runtimeJSON)
}
}

func TestRuntimeEnvBindingsInjectsSecretEnvWithoutEnsuredRefs(t *testing.T) {
const secret = "sk-secret-only"

env, err := RuntimeEnvBindings(nil, agentadaptor.RuntimePayload{
SecretEnv: []agentadaptor.EnvBinding{{Name: "DELEGATION_TOKEN", Value: secret}},
})
if err != nil {
t.Fatalf("runtime env bindings: %v", err)
}
if got := lastEnvValue(env, "DELEGATION_TOKEN"); got != secret {
t.Fatalf("expected runtime secret env without ensured refs, got %q in %#v", got, env)
}
if got := lastEnvValue(env, "PAPERCLIP_RUNTIME_SERVICES_JSON"); got != "" {
t.Fatalf("did not expect runtime services JSON without ensured refs, got %q", got)
}
}

func lastEnvValue(bindings []agentadaptor.EnvBinding, name string) string {
var out string
for _, binding := range bindings {
if binding.Name == name {
out = binding.Value
}
}
return out
}
Loading
Loading