diff --git a/agent/example/example_test.go b/agent/example/example_test.go index f712147d1..52c8736b8 100644 --- a/agent/example/example_test.go +++ b/agent/example/example_test.go @@ -59,10 +59,10 @@ func TestCapabilityMatrixDiverges(t *testing.T) { if ec.ArtifactDownload || ec.FileInput || ec.TaskCancel { t.Errorf("echo should be the minimal set (no artifact/file/cancel), got %+v", ec) } - if !ec.MultiTurn || !ec.TaskGet || !ec.TaskList { - t.Errorf("echo should support multi_turn/task_get/task_list, got %+v", ec) + if !ec.ContextList || !ec.ContextGet || !ec.ContextDelete || !ec.TaskGet || !ec.TaskList { + t.Errorf("echo should support context_list/get/delete + task_get/task_list, got %+v", ec) } - if !(rc.ArtifactDownload && rc.FileInput && rc.TaskCancel && rc.InputRequired && rc.MultiTurn && rc.TaskGet && rc.TaskList) { + if !(rc.ArtifactDownload && rc.FileInput && rc.TaskCancel && rc.InputRequired && rc.ContextList && rc.ContextGet && rc.ContextDelete && rc.TaskGet && rc.TaskList) { t.Errorf("reporter should have everything enabled, got %+v", rc) } } diff --git a/cmd/agent/card.go b/cmd/agent/card.go index dcb4c365e..5c65bfc15 100644 --- a/cmd/agent/card.go +++ b/cmd/agent/card.go @@ -139,9 +139,11 @@ func printCardPretty(w io.Writer, card *iagent.AgentCard) { // matching the sorted output of the earlier map-based representation. for _, k := range []string{ iagent.CapArtifactDownload, + iagent.CapContextDelete, + iagent.CapContextGet, + iagent.CapContextList, iagent.CapFileInput, iagent.CapInputRequired, - iagent.CapMultiTurn, iagent.CapTaskCancel, iagent.CapTaskGet, iagent.CapTaskList, diff --git a/cmd/agent/card_test.go b/cmd/agent/card_test.go index 62ce1ffda..cf8c88986 100644 --- a/cmd/agent/card_test.go +++ b/cmd/agent/card_test.go @@ -29,8 +29,8 @@ func cardTestOpts(t *testing.T, ref string) (*cardOptions, *core.CliConfig) { // TestAgentCardRun_ExampleStaticCard verifies that `agent card example:echo` // returns the statically synthesized capability card (no API), with -// task_cancel gated off and multi_turn on, and the agent_id echoed from the -// ref. +// task_cancel gated off and the three context_* caps on, and the agent_id +// echoed from the ref. func TestAgentCardRun_ExampleStaticCard(t *testing.T) { opts, _ := cardTestOpts(t, "example:echo") out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte }) @@ -67,8 +67,8 @@ func TestAgentCardRun_ExampleStaticCard(t *testing.T) { if caps["task_cancel"] != false { t.Errorf("echo task_cancel should be false, got %v", caps["task_cancel"]) } - if caps["multi_turn"] != true { - t.Errorf("echo multi_turn should be true, got %v", caps["multi_turn"]) + if caps["context_list"] != true || caps["context_get"] != true || caps["context_delete"] != true { + t.Errorf("echo should support the three context capabilities, got %v", caps) } // parameters / identity must serialize as non-null (guard against omitempty // regression): parameters is always an array (empty [] for example), @@ -110,8 +110,8 @@ func TestAgentCardRun_PrettyFormat(t *testing.T) { if !strings.Contains(text, "echo") { t.Errorf("pretty output should contain agent_id: %s", text) } - // multi_turn is a declared capability of the echo card; it must appear. - if !strings.Contains(text, "multi_turn") { + // context_list is a declared capability of the echo card; it must appear. + if !strings.Contains(text, "context_list") { t.Errorf("pretty output should list capabilities: %s", text) } } @@ -183,8 +183,8 @@ func TestPrintCardPretty_AllOptionalFields(t *testing.T) { {Type: "bot", Precondition: "需加入渠道白名单"}, }, Capabilities: iagent.Capabilities{ - MultiTurn: true, - TaskCancel: false, + ContextList: true, + TaskCancel: false, }, Parameters: []iagent.CardParam{ {Name: "locale", Type: "string", Required: true, Desc: "reply locale"}, diff --git a/cmd/agent/context.go b/cmd/agent/context.go index d4edb64ee..132cc3dad 100644 --- a/cmd/agent/context.go +++ b/cmd/agent/context.go @@ -29,7 +29,8 @@ type contextOptions struct { } // NewCmdAgentContext builds the `agent context` command group: manage a remote -// agent's multi-turn contexts (requires card multi_turn=true). It is a pure group with +// agent's multi-turn contexts (each verb gated on its own capability: +// context_list / context_get / context_delete). It is a pure group with // no RunE so an unknown subcommand is reported rather than silently swallowed. func NewCmdAgentContext(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ @@ -133,10 +134,10 @@ func agentContextListRun(opts *contextOptions) error { if err != nil { return err } - // Capability gate BEFORE the client: multi_turn is derived from ListContexts + // Capability gate BEFORE the client: context_list is derived from ListContexts // being wired, so a spec without it returns unsupported_capability offline. if spec.ListContexts == nil { - return capabilityError(opts.Ref, "context list", iagent.CapMultiTurn) + return capabilityError(opts.Ref, "context list", iagent.CapContextList) } rt, err := runtimeFor(f, id, agentID) if err != nil { @@ -175,7 +176,7 @@ func agentContextGetRun(opts *contextOptions) error { } // Capability gate BEFORE the client. if spec.GetContext == nil { - return capabilityError(opts.Ref, "context get", iagent.CapMultiTurn) + return capabilityError(opts.Ref, "context get", iagent.CapContextGet) } rt, err := runtimeFor(f, id, agentID) if err != nil { @@ -214,7 +215,7 @@ func agentContextDeleteRun(opts *contextOptions) error { } // Capability gate BEFORE the client. if spec.DeleteContext == nil { - return capabilityError(opts.Ref, "context delete", iagent.CapMultiTurn) + return capabilityError(opts.Ref, "context delete", iagent.CapContextDelete) } rt, err := runtimeFor(f, id, agentID) if err != nil { diff --git a/cmd/agent/task.go b/cmd/agent/task.go index f74ee1cd8..802ebac93 100644 --- a/cmd/agent/task.go +++ b/cmd/agent/task.go @@ -471,9 +471,9 @@ func downloadArtifact(opts *taskOptions) error { } // fetchArtifactURL is the production URL fetch: it SSRF-validates rawURL, builds -// a download-hardened HTTP client from the Factory and reads at most -// maxArtifactBytes of the body. The artifact host is untrusted external content, -// so both the URL and the redirect chain are guarded. +// a download-hardened HTTP client from the Factory and reads the body up to +// maxArtifactBytes, refusing anything larger. The artifact host is untrusted +// external content, so both the URL and the redirect chain are guarded. func fetchArtifactURL(ctx context.Context, f *cmdutil.Factory, rawURL string) ([]byte, error) { if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil { return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "被拦截的产物 URL: %v", err). @@ -504,9 +504,18 @@ func fetchArtifactURL(ctx context.Context, f *cmdutil.Factory, rawURL string) ([ if resp.StatusCode != http.StatusOK { return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "下载产物失败: HTTP %d", resp.StatusCode) } - data, err := io.ReadAll(io.LimitReader(resp.Body, maxArtifactBytes)) + // Read ONE byte past the cap so an oversized body is detected rather than + // silently truncated: io.LimitReader returns EOF (not an error) at the cap, so + // reading exactly maxArtifactBytes cannot distinguish "fits" from "overflowed". + // A body over the cap is refused with a typed error instead of writing a + // corrupt, partial file that would otherwise report success. + data, err := io.ReadAll(io.LimitReader(resp.Body, maxArtifactBytes+1)) if err != nil { return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "读取产物响应失败: %v", err).WithCause(err) } + if int64(len(data)) > maxArtifactBytes { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "产物超过大小上限 %d 字节,拒绝下载(避免写入被截断的残缺文件)", int64(maxArtifactBytes)) + } return data, nil } diff --git a/cmd/agent/task_test.go b/cmd/agent/task_test.go index 4d9215857..8744b90b1 100644 --- a/cmd/agent/task_test.go +++ b/cmd/agent/task_test.go @@ -847,28 +847,29 @@ func TestFetchArtifactURL_Success(t *testing.T) { } } -// TestFetchArtifactURL_LimitEnforced pins the io.LimitReader(maxArtifactBytes) -// guard: an oversized body is truncated at exactly maxArtifactBytes so a -// hostile host cannot stream an unbounded body onto disk. Uses a streaming -// RoundTripper that would otherwise emit far more than the cap. +// TestFetchArtifactURL_LimitEnforced pins the size-cap guard: a body larger than +// maxArtifactBytes is REJECTED with a typed error rather than silently truncated +// onto disk (the fetch reads max+1 to detect the overflow), so a hostile host can +// neither stream an unbounded body nor slip a corrupt partial file past as +// success. Uses a streaming RoundTripper that emits one byte past the cap. func TestFetchArtifactURL_LimitEnforced(t *testing.T) { restore := swapHardenDownloadClient(passthroughClient) defer restore() cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu} f, _, _, _ := cmdutil.TestFactory(t, cfg) - // A body one byte longer than the cap; LimitReader must stop at the cap. + // A body one byte longer than the cap must be refused, not truncated. oversized := int64(maxArtifactBytes) + 1 f.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: streamRoundTripper{n: oversized, b: 'A'}}, nil } - got, err := fetchArtifactURL(context.Background(), f, publicArtifactURL) - if err != nil { - t.Fatalf("an oversized download should not error (should truncate, not fail): %v", err) + _, err := fetchArtifactURL(context.Background(), f, publicArtifactURL) + if err == nil { + t.Fatal("an oversized artifact should be rejected with an error, not truncated to a partial file") } - if int64(len(got)) != int64(maxArtifactBytes) { - t.Fatalf("downloaded bytes should be truncated to %d, got %d", maxArtifactBytes, len(got)) + if !errs.IsValidation(err) { + t.Fatalf("oversized artifact should be a validation error, got %T: %v", err, err) } } diff --git a/internal/agent/card.go b/internal/agent/card.go index a04a83d2f..26ed9d5b4 100644 --- a/internal/agent/card.go +++ b/internal/agent/card.go @@ -16,18 +16,25 @@ const ( CapInputRequired = "input_required" CapFileInput = "file_input" CapArtifactDownload = "artifact_download" - CapMultiTurn = "multi_turn" + // The three multi-turn (context) verbs are independently wired, so each has + // its own capability bit — a provider may support listing sessions without + // supporting get or delete. (There is no umbrella "multi_turn" bit: a single + // flag cannot honestly represent three separately-deliverable hooks.) + CapContextList = "context_list" + CapContextGet = "context_get" + CapContextDelete = "context_delete" ) // Capabilities is the closed set of capabilities: making it a struct means an // omitted field is an explicit false and a typo is a compile error. Fields are -// ordered by json tag alphabetically to keep the key order identical to the old -// map serialization. +// ordered by json tag alphabetically so the emitted key order is stable. type Capabilities struct { ArtifactDownload bool `json:"artifact_download"` + ContextDelete bool `json:"context_delete"` + ContextGet bool `json:"context_get"` + ContextList bool `json:"context_list"` FileInput bool `json:"file_input"` InputRequired bool `json:"input_required"` - MultiTurn bool `json:"multi_turn"` TaskCancel bool `json:"task_cancel"` TaskGet bool `json:"task_get"` TaskList bool `json:"task_list"` @@ -62,7 +69,9 @@ func DeriveCapabilities(s *AgentSpec) Capabilities { TaskList: s.ListTasks != nil, TaskCancel: s.CancelTask != nil, ArtifactDownload: s.DownloadArtifact != nil, - MultiTurn: s.ListContexts != nil, + ContextList: s.ListContexts != nil, + ContextGet: s.GetContext != nil, + ContextDelete: s.DeleteContext != nil, FileInput: s.FileInput, InputRequired: s.InputRequired, } @@ -145,8 +154,12 @@ func (c *AgentCard) Supports(capKey string) bool { return c.Capabilities.FileInput case CapInputRequired: return c.Capabilities.InputRequired - case CapMultiTurn: - return c.Capabilities.MultiTurn + case CapContextList: + return c.Capabilities.ContextList + case CapContextGet: + return c.Capabilities.ContextGet + case CapContextDelete: + return c.Capabilities.ContextDelete case CapTaskCancel: return c.Capabilities.TaskCancel case CapTaskGet: diff --git a/internal/agent/card_test.go b/internal/agent/card_test.go index 726964c64..7bb6eee39 100644 --- a/internal/agent/card_test.go +++ b/internal/agent/card_test.go @@ -24,32 +24,34 @@ func (fakeRT) CallMultipart(context.Context, string, string, map[string]string, } func TestCardSupports(t *testing.T) { - c := &AgentCard{Capabilities: Capabilities{TaskCancel: false, MultiTurn: true}} + c := &AgentCard{Capabilities: Capabilities{TaskCancel: false, ContextList: true}} if c.Supports(CapTaskCancel) { t.Error("task_cancel should not be supported") } - if !c.Supports(CapMultiTurn) { - t.Error("multi_turn should be supported") + if !c.Supports(CapContextList) { + t.Error("context_list should be supported") } if c.Supports("nonexistent") { t.Error("unknown capability should be treated as unsupported") } // nil guard branch: a nil receiver is treated as unsupported; a zero-value Capabilities is all false. var nilCard *AgentCard - if nilCard.Supports(CapMultiTurn) { + if nilCard.Supports(CapContextList) { t.Error("nil card should be treated as unsupported") } - if (&AgentCard{}).Supports(CapMultiTurn) { + if (&AgentCard{}).Supports(CapContextList) { t.Error("zero-value Capabilities should be treated as unsupported") } // Each capability constant must map to its own struct field (the switch has no gaps or mismatches). all := &AgentCard{Capabilities: Capabilities{ ArtifactDownload: true, FileInput: true, InputRequired: true, - MultiTurn: true, TaskCancel: true, TaskGet: true, TaskList: true, + ContextList: true, ContextGet: true, ContextDelete: true, + TaskCancel: true, TaskGet: true, TaskList: true, }} for _, k := range []string{ CapArtifactDownload, CapFileInput, CapInputRequired, - CapMultiTurn, CapTaskCancel, CapTaskGet, CapTaskList, + CapContextList, CapContextGet, CapContextDelete, + CapTaskCancel, CapTaskGet, CapTaskList, } { if !all.Supports(k) { t.Errorf("Supports(%q) should be true when all Capabilities are true", k) @@ -66,10 +68,12 @@ func TestDeriveCapabilities(t *testing.T) { if !c.TaskGet { t.Error("task_get should be true (GetTask is a mandatory core hook)") } - if !c.MultiTurn { - t.Error("multi_turn should be true (ListContexts wired)") + if !c.ContextList { + t.Error("context_list should be true (ListContexts wired)") } - if c.TaskCancel || c.ArtifactDownload || c.TaskList || c.FileInput || c.InputRequired { + // The three context caps are independent: only ListContexts is wired here, so + // context_get / context_delete stay false (no umbrella multi_turn bit). + if c.TaskCancel || c.ArtifactDownload || c.TaskList || c.FileInput || c.InputRequired || c.ContextGet || c.ContextDelete { t.Errorf("unwired capabilities should be false, got %+v", c) } @@ -78,11 +82,13 @@ func TestDeriveCapabilities(t *testing.T) { full.ListTasks = func(context.Context, Runtime, string) ([]TaskSummary, error) { return nil, nil } full.CancelTask = func(context.Context, Runtime, string) error { return nil } full.ListContexts = func(context.Context, Runtime) ([]ContextSummary, error) { return nil, nil } + full.GetContext = func(context.Context, Runtime, string) (*ContextDetail, error) { return nil, nil } + full.DeleteContext = func(context.Context, Runtime, string) error { return nil } full.DownloadArtifact = func(context.Context, Runtime, string, string) (*ArtifactData, error) { return nil, nil } full.FileInput = true full.InputRequired = true c = DeriveCapabilities(&full) - if !(c.TaskGet && c.TaskList && c.TaskCancel && c.MultiTurn && c.ArtifactDownload && c.FileInput && c.InputRequired) { + if !(c.TaskGet && c.TaskList && c.TaskCancel && c.ContextList && c.ContextGet && c.ContextDelete && c.ArtifactDownload && c.FileInput && c.InputRequired) { t.Errorf("a fully-wired spec should have every capability true, got %+v", c) } } diff --git a/skills/lark-agent/SKILL.md b/skills/lark-agent/SKILL.md index 980260a1d..bfc5856bc 100644 --- a/skills/lark-agent/SKILL.md +++ b/skills/lark-agent/SKILL.md @@ -81,7 +81,7 @@ metadata: - `submitted` / `working` → 还在跑,稍后再 `task get`(或 `--watch`) - **停轮询条件** = `is_terminal`(∈{completed,failed,canceled,rejected})为真 **或** state ∈ {`input_required`,`auth_required`}(后两者不是错误,是"该你续发了")。 - **artifact**:任务产出物(图/文件),列在 `data.artifacts[]`(每项含 `id` + 粗粒度 `kind` 提示);用 `task get --artifact -o ` 落盘。选 `-o` 后缀看 `kind`(下载前)与下载输出的 `suggested_name`(下载后,带扩展名);两者仅参考,落盘以 `-o` 为准。 -- **能力门控**:card `capabilities` 共 7 键(`task_get/task_list/task_cancel/input_required/file_input/artifact_download/multi_turn`),为 false 的动词报 `unsupported_capability`,不静默降级。context 动词无独立键,由 `multi_turn` 伞形覆盖:`multi_turn=false` 时别调 `context list/get/delete`。card 无键的低频能力由运行时兜底——调用报 `unsupported_capability` 与 card 为 false 同样权威,别重试。能力以 `agent card` 实际输出为准;provider 特例见对应 provider 文件。 +- **能力门控**:card `capabilities` 共 9 键(`task_get/task_list/task_cancel/input_required/file_input/artifact_download/context_list/context_get/context_delete`),为 false 的动词报 `unsupported_capability`,不静默降级。context 三个动词各有独立能力位(`context_list/context_get/context_delete`)——一个 provider 可能能列会话却不能删会话,按需分别判断,别用单一位一概而论。card 无键的低频能力由运行时兜底——调用报 `unsupported_capability` 与 card 为 false 同样权威,别重试。能力以 `agent card` 实际输出为准;provider 特例见对应 provider 文件。 ## 异步与轮询(子进程契约) diff --git a/skills/lark-agent/references/lark-agent-card.md b/skills/lark-agent/references/lark-agent-card.md index 6d1049a51..5ead2db13 100644 --- a/skills/lark-agent/references/lark-agent-card.md +++ b/skills/lark-agent/references/lark-agent-card.md @@ -43,9 +43,11 @@ lark-cli agent card : --jq '.data.capabilities' "description": "把你发的话原样复读一遍(同一会话续发时带轮次,证明上下文记忆)。最小能力集示范。", "capabilities": { "artifact_download": false, + "context_delete": true, + "context_get": true, + "context_list": true, "file_input": false, "input_required": false, - "multi_turn": true, "task_cancel": false, "task_get": true, "task_list": true diff --git a/skills/lark-agent/references/lark-agent-context.md b/skills/lark-agent/references/lark-agent-context.md index b13871865..3a0eda4f9 100644 --- a/skills/lark-agent/references/lark-agent-context.md +++ b/skills/lark-agent/references/lark-agent-context.md @@ -2,7 +2,7 @@ > **前置条件:** 先读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(含高危 exit-10 确认机制)。 -管理远程 agent 的**多轮上下文(会话)**。一个 context(`context_id`)串起同一会话里的多个任务;需 card `multi_turn=true`。续发/追问在 [`agent send --context-id`](lark-agent-send.md),不在此。三个动词都要求该 provider 的全部 scope(all-or-nothing;缺任一即本地报 `missing_scope`,照抄 hint 授权;scope 全集见 provider 文件)。 +管理远程 agent 的**多轮上下文(会话)**。一个 context(`context_id`)串起同一会话里的多个任务;三个动词各由 `context_list` / `context_get` / `context_delete` 能力位分别门控(provider 可能只支持其中一部分,以 `agent card` 为准)。续发/追问在 [`agent send --context-id`](lark-agent-send.md),不在此。三个动词都要求该 provider 的全部 scope(all-or-nothing;缺任一即本地报 `missing_scope`,照抄 hint 授权;scope 全集见 provider 文件)。 **分诊心法**:`context list`(哪个会话要处理)→ `context get`(该会话总览 + `active_task`)→ [`agent task list --context-id`](lark-agent-task.md)(该会话全部任务)→ [`agent task get`](lark-agent-task.md)(单任务完整详情)。 diff --git a/skills/lark-agent/references/providers/lark-agent-example.md b/skills/lark-agent/references/providers/lark-agent-example.md index 9609f412f..f64b1d70e 100644 --- a/skills/lark-agent/references/providers/lark-agent-example.md +++ b/skills/lark-agent/references/providers/lark-agent-example.md @@ -39,7 +39,7 @@ catalog 型必可枚举,`agent list example` 直接列全部 agent(含 name/ | capability | `example:echo` | `example:reporter` | 差异含义 | |---|---|---|---| -| `task_get` / `task_list` / `multi_turn` | true | true | 两者都支持查任务、列任务、多轮会话 | +| `task_get` / `task_list` / `context_list` / `context_get` / `context_delete` | true | true | 两者都支持查/列任务与多轮会话(列/查/删会话)| | `task_cancel` | **false** | true | 对 echo 发 cancel 被命令层门控直接拒(见下方错误样例,不发任何请求);对 reporter 的 cancel 会真正派发(但 mock 任务即时终态,见下方 failed_precondition 样例) | | `file_input` | **false** | true | echo 带 `--file` 报 `unsupported_capability`;reporter 接收附件并在回复里确认 | | `artifact_download` | **false** | true | 只有 reporter 产出 artifact(内联 CSV,`kind=text`,下载输出 `mime=text/csv`、`suggested_name=quarterly_report.csv`) |