feat: add example provider, StaticCatalog scaffolding and conformance harness

- StaticCatalog (internal/agent/catalog.go): a framework helper carrying
  the catalog-provider boilerplate — enumeration, per-agent card lookup
  and typed unknown-id errors — so catalog providers do not reinvent it.
- agenttest.RunConformance: a one-call conformance suite pinning the SPI's
  implicit contracts (registration metadata, zero-Deps factory, card
  single source, enumeration stability).
- example provider (internal/agent/example): an offline in-memory
  reference provider (echo / reporter, deliberately different capability
  matrices) that doubles as the provider-onboarding template and the
  command tree's zero-network demo backend.
This commit is contained in:
liuxinyang.lxy
2026-07-08 11:48:37 +08:00
parent f837ebf64e
commit 6c8ea37340
6 changed files with 1400 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package agenttest provides provider conformance tests: a new integrator calls
// RunConformance in its own test to lock down registration metadata, the
// zero-value Deps contract, Card single-sourcing, and other implicit contracts.
// All assertions run offline (zero-value Deps, no API calls).
package agenttest
import (
"context"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/agent"
)
// RunConformance runs the full set of conformance assertions against a
// registered scheme. sampleAgentID must be a valid agent id for which the
// provider can produce a Card (catalog-type: an id from the catalog;
// instance-type: any non-empty id).
func RunConformance(t *testing.T, scheme, sampleAgentID string) {
t.Helper()
info, ok := agent.Info(scheme)
if !ok {
t.Fatalf("conformance: scheme %q not registered (the provider package must be imported to trigger init registration)", scheme)
}
t.Run("metadata", func(t *testing.T) {
if info.Label == "" {
t.Error("conformance: ProviderInfo.Label must not be empty")
}
if info.AgentIDSource == "" {
t.Error("conformance: ProviderInfo.AgentIDSource must not be empty")
}
if info.Kind != agent.KindCatalog && info.Kind != agent.KindInstance {
t.Errorf("conformance: Kind should be %q|%q, got %q", agent.KindCatalog, agent.KindInstance, info.Kind)
}
if !strings.HasPrefix(info.AgentRefFormat, scheme+":") {
t.Errorf("conformance: AgentRefFormat should start with %q, got %q", scheme+":", info.AgentRefFormat)
}
if len(info.Identities) == 0 {
t.Error("conformance: Identities must not be empty")
}
for i, id := range info.Identities {
if id.Type != agent.IdentityUser && id.Type != agent.IdentityBot {
t.Errorf("conformance: Identities[%d].Type should be user|bot, got %q", i, id.Type)
}
}
seen := make(map[string]bool, len(info.RequiredScopes))
for _, s := range info.RequiredScopes {
if seen[s] {
t.Errorf("conformance: RequiredScopes contains duplicate %q", s)
}
seen[s] = true
}
})
t.Run("factory", func(t *testing.T) {
p, err := info.Factory(agent.Deps{}, sampleAgentID)
if err != nil {
t.Fatalf("conformance: Factory must accept zero-value Deps (expected nil error), got %v", err)
}
if p == nil {
t.Fatal("conformance: Factory must not return a nil provider")
}
})
t.Run("card", func(t *testing.T) {
newCard := func() *agent.AgentCard {
t.Helper()
p, err := info.Factory(agent.Deps{}, sampleAgentID)
if err != nil {
t.Fatalf("conformance: Factory(zero-value Deps) returned error: %v", err)
}
card, err := p.Card(context.Background())
if err != nil {
t.Fatalf("conformance: Card should be available offline (expected nil error), got %v", err)
}
if card == nil {
t.Fatal("conformance: Card must not return nil")
}
return card
}
card := newCard()
if card.Provider != scheme {
t.Errorf("conformance: Card.Provider should be %q, got %q", scheme, card.Provider)
}
if card.AgentID != sampleAgentID {
t.Errorf("conformance: Card.AgentID should echo the constructor input %q, got %q", sampleAgentID, card.AgentID)
}
if card.ProviderLabel != info.Label {
t.Errorf("conformance: Card.ProviderLabel should equal the registered Label %q, got %q", info.Label, card.ProviderLabel)
}
if !reflect.DeepEqual(card.Identity, info.Identities) {
t.Errorf("conformance: Card.Identity should match the registered Identities (single source), expected %+v got %+v", info.Identities, card.Identity)
}
if card.AgentIDSource != info.AgentIDSource {
t.Errorf("conformance: Card.AgentIDSource should equal the registered value %q, got %q", info.AgentIDSource, card.AgentIDSource)
}
if card.Parameters == nil {
t.Error("conformance: Card.Parameters must not be nil (always emitted, empty is [])")
}
// Single-sourcing: two independent instances each produce a Card, and the
// results must DeepEqual (no hidden instance state).
if card2 := newCard(); !reflect.DeepEqual(card, card2) {
t.Errorf("conformance: Cards from two instances should DeepEqual (single source), got\n%+v\nvs\n%+v", card, card2)
}
})
if info.Kind == agent.KindCatalog {
t.Run("discovery", func(t *testing.T) {
p, err := info.Factory(agent.Deps{}, sampleAgentID)
if err != nil {
t.Fatalf("conformance: Factory(zero-value Deps) returned error: %v", err)
}
d, ok := p.(agent.Discoverer)
if !ok {
t.Fatal("conformance: catalog-type provider instances must implement Discoverer")
}
list, err := d.ListAgents(context.Background())
if err != nil {
t.Fatalf("conformance: catalog-type ListAgents should be available offline (expected nil error), got %v", err)
}
wantRef := scheme + ":" + sampleAgentID
found := false
for i, a := range list {
r, err := agent.ParseRef(a.AgentRef)
if err != nil {
t.Errorf("conformance: ListAgents[%d].AgentRef %q should be parseable by agent.ParseRef: %v", i, a.AgentRef, err)
continue
}
if r.Scheme != scheme {
t.Errorf("conformance: ListAgents[%d].AgentRef %q scheme should be %q, got %q", i, a.AgentRef, scheme, r.Scheme)
}
if a.Name == "" {
t.Errorf("conformance: ListAgents[%d] (%s) Name must not be empty", i, a.AgentRef)
}
if a.AgentRef == wantRef {
found = true
}
}
if !found {
t.Errorf("conformance: sampleAgentID should appear in the enumeration (expected to contain %q), got %+v", wantRef, list)
}
list2, err := d.ListAgents(context.Background())
if err != nil {
t.Fatalf("conformance: second ListAgents returned error: %v", err)
}
if !reflect.DeepEqual(list, list2) {
t.Errorf("conformance: two consecutive ListAgents results should DeepEqual (stable enumeration), got\n%+v\nvs\n%+v", list, list2)
}
})
}
}

91
internal/agent/catalog.go Normal file
View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"sort"
"github.com/larksuite/cli/errs"
)
// CatalogEntry is the provider-neutral description of one predefined agent in a
// catalog-type provider. It holds only fields the framework can consume
// (enumeration / Card synthesis); a provider's private business fields (such as
// the execution backend it points to) are maintained alongside in the
// integrator's own package and do not enter framework types.
type CatalogEntry struct {
ID string
Name string
Description string
Capabilities Capabilities
}
// StaticCatalog carries the common boilerplate of a catalog-type provider:
// catalog enumeration (Discoverer semantics), per-agent rich Card (NewCard fills
// in registration-time fields, the entry adds Name/Description/Capabilities), and
// a typed validation error for unknown ids. Business differences (such as the
// execution backend) are composed by the provider itself on the outer layer.
// It is read-only after construction and safe for concurrent use.
type StaticCatalog struct {
scheme string
entries map[string]CatalogEntry
}
// NewStaticCatalog constructs a static catalog. A duplicate entry ID is an
// integrator coding error and panics fail-fast (aligned with the Register convention).
func NewStaticCatalog(scheme string, entries []CatalogEntry) *StaticCatalog {
m := make(map[string]CatalogEntry, len(entries))
for _, e := range entries {
if _, dup := m[e.ID]; dup {
panic("agent: StaticCatalog duplicate entry ID for scheme " + scheme + ": " + e.ID)
}
m[e.ID] = e
}
return &StaticCatalog{scheme: scheme, entries: m}
}
// ListAgents enumerates the catalog (Discoverer semantics), sorted by AgentRef
// to guarantee stable output.
func (c *StaticCatalog) ListAgents(ctx context.Context) ([]AgentSummary, error) {
out := make([]AgentSummary, 0, len(c.entries))
for _, e := range c.entries {
out = append(out, AgentSummary{
AgentRef: c.scheme + ":" + e.ID,
Name: e.Name,
Description: e.Description,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].AgentRef < out[j].AgentRef })
return out, nil
}
// Card synthesizes the agent's rich card: NewCard fills in registration-time
// fields (Provider/Label/Identity/AgentIDSource/empty Parameters), and the entry
// adds the per-agent Name/Description/Capabilities. An unknown id returns the
// typed error from Lookup.
func (c *StaticCatalog) Card(agentID string) (*AgentCard, error) {
e, err := c.Lookup(agentID)
if err != nil {
return nil, err
}
card := NewCard(c.scheme, agentID)
card.Name = e.Name
card.Description = e.Description
card.Capabilities = e.Capabilities
return card, nil
}
// Lookup fetches a catalog entry by id. An unknown id returns a typed
// validation/invalid_argument error (exit 2) whose hint points to
// `agent list <scheme>`, so a provider need not define its own unknown-agent error.
func (c *StaticCatalog) Lookup(agentID string) (CatalogEntry, error) {
e, ok := c.entries[agentID]
if !ok {
return CatalogEntry{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 %s agent '%s'", c.scheme, agentID).
WithHint("运行 lark-cli agent list %s 查看可用 agent", c.scheme)
}
return e, nil
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
)
// testCatalogEntries is declared out of order to verify ListAgents sorting stability.
func testCatalogEntries() []CatalogEntry {
return []CatalogEntry{
{ID: "zeta", Name: "Zeta 助手", Description: "z desc",
Capabilities: Capabilities{TaskGet: true}},
{ID: "alpha", Name: "Alpha 助手", Description: "a desc",
Capabilities: Capabilities{TaskGet: true, MultiTurn: true}},
}
}
// TestStaticCatalogListAgentsSorted asserts the enumeration is sorted by AgentRef
// and that two consecutive results are DeepEqual (stable sort, the same contract
// asserted by agenttest discovery).
func TestStaticCatalogListAgentsSorted(t *testing.T) {
c := NewStaticCatalog("cattest", testCatalogEntries())
got, err := c.ListAgents(context.Background())
if err != nil {
t.Fatal(err)
}
want := []AgentSummary{
{AgentRef: "cattest:alpha", Name: "Alpha 助手", Description: "a desc"},
{AgentRef: "cattest:zeta", Name: "Zeta 助手", Description: "z desc"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ListAgents should be sorted by AgentRef, want %+v got %+v", want, got)
}
got2, err := c.ListAgents(context.Background())
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, got2) {
t.Fatalf("two consecutive ListAgents calls should be DeepEqual, got\n%+v\nvs\n%+v", got, got2)
}
}
// TestStaticCatalogCard asserts Card = NewCard pre-filling registration-time fields
// plus the entry filling in Name/Description/Capabilities.
func TestStaticCatalogCard(t *testing.T) {
swapRegistry(t, map[string]ProviderInfo{})
Register("cattest", testInfo("cattest",
func(Deps, string) (Provider, error) { return &stubProvider{}, nil }))
info, _ := Info("cattest")
c := NewStaticCatalog("cattest", testCatalogEntries())
card, err := c.Card("alpha")
if err != nil {
t.Fatal(err)
}
if card.Provider != "cattest" || card.AgentID != "alpha" {
t.Fatalf("provider/agent_id: %+v", card)
}
if card.ProviderLabel != info.Label || card.AgentIDSource != info.AgentIDSource {
t.Fatalf("registration-time fields should be pre-filled by NewCard: %+v", card)
}
if !reflect.DeepEqual(card.Identity, info.Identities) {
t.Fatalf("Identity should match the registered value: %+v", card.Identity)
}
if card.Parameters == nil {
t.Fatal("Parameters must not be nil (always emitted, empty is [])")
}
if card.Name != "Alpha 助手" || card.Description != "a desc" {
t.Fatalf("entry should fill in Name/Description: %+v", card)
}
wantCaps := Capabilities{TaskGet: true, MultiTurn: true}
if card.Capabilities != wantCaps {
t.Fatalf("entry should fill in Capabilities, want %+v got %+v", wantCaps, card.Capabilities)
}
}
// TestStaticCatalogUnknownID asserts Lookup / Card return a typed
// validation/invalid_argument error for an unknown id (exit 2 rather than
// internal/exit 5), with the hint pointing to `agent list <scheme>`.
func TestStaticCatalogUnknownID(t *testing.T) {
c := NewStaticCatalog("cattest", testCatalogEntries())
_, err := c.Lookup("nonexistent")
if err == nil {
t.Fatal("unknown id should return an error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("unknown id should be an *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %q", ve.Subtype)
}
if want := "未知的 cattest agent 'nonexistent'"; ve.Message != want {
t.Fatalf("message should be %q, got %q", want, ve.Message)
}
if want := "运行 lark-cli agent list cattest 查看可用 agent"; ve.Hint != want {
t.Fatalf("hint should be %q, got %q", want, ve.Hint)
}
// Card goes through the same Lookup path.
if _, err := c.Card("nonexistent"); !errors.As(err, &ve) {
t.Fatalf("Card with an unknown id should return the same typed error, got %v", err)
}
}
// TestStaticCatalogDuplicateIDPanic asserts a duplicate entry ID triggers a
// fail-fast panic (aligned with the Register convention).
func TestStaticCatalogDuplicateIDPanic(t *testing.T) {
entries := []CatalogEntry{{ID: "a"}, {ID: "a"}}
mustPanic(t, "duplicate entry ID", func() { NewStaticCatalog("cattest", entries) })
}

View File

@@ -0,0 +1,391 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package example is the in-repo agent provider onboarding template and offline
// demo backend: a hypothetical example business domain whose data / calls are
// entirely in-memory mocks, with zero network. It has three roles:
//
// 1. A copy-start point for new integrators — copy the whole package and rename
// it; every key decision point carries a teaching comment from the
// "integrator's perspective" (how to fill registration fields, why typed
// errors are required, how to make capability-matrix trade-offs);
// 2. The command tree's offline demo backend — the full agent
// list/card/send/task/context chain runs for real without any platform
// configuration;
// 3. A stable mock scheme for cmd-layer tests.
//
// Minimal checklist for onboarding a new provider (each item is demonstrated in
// this package):
// - register metadata via agent.Register in init() (see the per-field comments below);
// - implement the 9 methods of agent.Provider (internal/agent/provider.go);
// - a catalog type (KindCatalog) must implement agent.Discoverer (asserted at registration time);
// - add a blank import in cmd/agent/agent.go to trigger init registration;
// - run agenttest.RunConformance in tests to lock down implicit contracts.
package example
import (
"context"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/agent"
)
// scheme is this provider's ref prefix (example:<agent_id>). It is globally
// unique; duplicate registration panics during init (aligned with the
// sql.Register convention, fail-fast to expose onboarding errors).
const scheme = "example"
// catalog is the full agent set known at registration time. The catalog
// boilerplate (enumeration / per-agent rich Card / typed error for unknown ids)
// is entirely handled by the framework's StaticCatalog; the integrator only
// declares the data.
//
// Teaching focus — the capability matrix must be "declared honestly":
// - The two agents' matrices differ on purpose: echo is the minimal set (no
// artifact, no file input, not cancelable), reporter has everything on. The
// AI decides its next command from the card, so whatever is declared must be
// delivered.
// - The asymmetry is directional: declaring something you "can't do" as true
// leads the AI into a dead end (e.g. declaring input_required true while the
// task never stops in that state, so the AI waits forever for a state that
// never appears); conversely, declaring an "unused" capability as true when
// the state never appears (like reporter's input_required) merely means the
// AI never uses it and does not go wrong. Only the former warrants being
// conservatively false.
// - task_cancel being true for one and false for the other is not arbitrary:
// echo=false demonstrates the command layer's card gating
// (cmd/agent/task.go intercepts and reports unsupported_capability before
// sending any request), while reporter=true demonstrates actually reaching
// the provider's CancelTask.
var catalog = agent.NewStaticCatalog(scheme, []agent.CatalogEntry{
{
ID: "echo",
Name: "复读机",
Description: "把你发的话原样复读一遍(同一会话续发时带轮次,证明上下文记忆)。最小能力集示范。",
Capabilities: agent.Capabilities{
TaskGet: true,
TaskList: true,
// echo is not cancelable: once the command layer reads false it
// rejects task cancel outright, so the provider is never even called
// (gate-before-network).
TaskCancel: false,
// The mock task completes instantly and never stops in
// input_required; echo honestly declares false per the minimal set.
InputRequired: false,
// echo takes no files and produces no artifact — Send returns
// agent.ErrUnsupported for --file, consistent with the false here
// (declaration and behavior single-sourced).
FileInput: false,
ArtifactDownload: false,
MultiTurn: true,
},
},
{
ID: "reporter",
Name: "报表生成器",
Description: "对任意请求产出一份内联 CSV 报表 artifact示范 artifact 下载与任务取消链路。",
Capabilities: agent.Capabilities{
TaskGet: true,
TaskList: true,
TaskCancel: true,
InputRequired: true,
FileInput: true,
ArtifactDownload: true,
MultiTurn: true,
},
},
})
func init() {
// Registration contract (internal/agent/registry.go): everything except
// RequiredScopes is required; missing / invalid values panic. At
// registration time it also constructs a Provider once via a zero-value Deps
// probe — so Factory must accept zero-value Deps and have no side effects
// during construction (no network, no disk).
agent.Register(scheme, agent.ProviderInfo{
Factory: newExampleProvider,
// Label: the user-facing provider name (the LABEL column in agent list).
Label: "Example 演示 agent内存 mock零网络",
// AgentRefFormat: the written format of agent_ref, must start with "<scheme>:" (validated at registration).
AgentRefFormat: "example:<agent_id>",
// AgentIDSource: tells the user / AI where to get the agent_id — key
// information for AI-guided onboarding, referenced by the unknown-id hint
// and the not-discoverable list hint.
AgentIDSource: "运行 lark-cli agent list example 查看内置演示 agent 及其 agent_ref无需任何平台配置",
// Kind: catalog type. Registration asserts the instance implements
// Discoverer, so `agent list example` can enumerate (an instance-type
// provider that does not support enumeration returns unsupported_capability).
Kind: agent.KindCatalog,
// RequiredScopes: the full set of scopes this provider's real API calls
// need. example has zero network and calls no OAPI, so it is empty —
// scope preflight (cmd/agent/preflight.go) always passes for the empty
// set. A real provider must list every scope used by any verb (preflight
// is all-or-nothing).
RequiredScopes: nil,
// Identities: supported calling identities and their preconditions. The
// mock treats user/bot alike; if a real provider has a precondition for
// some identity (e.g. a bot needs channel whitelisting), put it in
// Precondition and the card passes it through to the AI verbatim.
Identities: []agent.IdentitySpec{
{Type: agent.IdentityUser},
{Type: agent.IdentityBot},
},
})
}
// exampleProvider addresses one agent in the catalog. agentID may be empty — the
// Discoverer path (agent list example) constructs an instance without an id only
// to enumerate the catalog.
type exampleProvider struct {
deps agent.Deps
agentID string
}
// newExampleProvider is the registered Factory. Teaching point: it does pure
// assignment only.
// - It does not validate agentID: an unknown id is rejected by catalog.Lookup
// inside "the verbs that actually use the id" (the empty-id enumeration
// instance must construct successfully);
// - It does not touch deps: the mock has no use for Client/As; a real provider
// uses deps.Client to send requests and deps.As to pick the identity in its
// methods, but construction must still have no side effects (the zero-value
// Deps probe contract).
func newExampleProvider(deps agent.Deps, agentID string) (agent.Provider, error) {
return &exampleProvider{deps: deps, agentID: agentID}, nil
}
// Card returns the agent's capability card. Synthesis is fully offline: NewCard
// fills in registration-time fields (Provider/Label/Identity/AgentIDSource/empty
// Parameters), and the catalog entry adds Name/Description/Capabilities. An
// unknown id returns a typed validation error from StaticCatalog.Lookup
// (invalid_argument/exit 2, hint pointing to agent list example) — the
// integrator need not build its own unknown-agent error.
func (p *exampleProvider) Card(ctx context.Context) (*agent.AgentCard, error) {
return catalog.Card(p.agentID)
}
// ListAgents enumerates the catalog (Discoverer): `agent list example` goes here.
func (p *exampleProvider) ListAgents(ctx context.Context) ([]agent.AgentSummary, error) {
return catalog.ListAgents(ctx)
}
// Send sends one message: the first turn generates a context_id to start a new
// conversation, and --context-id continues within the same conversation. The
// mock task has no async execution body, so Send immediately returns in the
// completed terminal state — the command layer's meta.next therefore directly
// gives the terminal-state suggestion "view task detail and artifacts" rather
// than a polling command.
//
// Teaching point (IsTerminal): IsTerminal is filled in here for convenience, but
// leaving it out would be fine — the command layer's normalizeTask always
// re-derives this field from State (single source), so a provider filling it in
// wrong does not affect the watch exit code. The integrator need not worry about it.
func (p *exampleProvider) Send(ctx context.Context, in agent.SendInput) (*agent.AgentTask, error) {
entry, err := catalog.Lookup(p.agentID)
if err != nil {
return nil, err
}
// Capability declaration and behavior are single-sourced: if the card says it
// takes no files, the behavior must reject them. The command layer does not
// card-gate --file (only cancel is gated), so the provider returns the
// agent.ErrUnsupported sentinel, and the command layer's convertUnsupported
// turns it into a typed unsupported_capability (exit 2) — the full
// sentinel→typed chain.
if len(in.Files) > 0 && !entry.Capabilities.FileInput {
return nil, fmt.Errorf("example:%s 不接收文件输入: %w", p.agentID, agent.ErrUnsupported)
}
// The mock task is instantly terminal, so there is no "feed input to a running
// task" scenario. Continuing via --task-id returns failed_precondition: the
// request itself is valid but the target resource's state does not satisfy it
// — reading this subtype, the AI knows to "try a different way" (start a new
// task) rather than retry as-is.
if in.TaskID != "" {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"example 的任务发出即完成(终态),无法向已有任务续发").
WithParam("--task-id").
WithHint("去掉 --task-id用 --context-id 在同一会话起新一轮任务")
}
ctxID := in.ContextID
if ctxID == "" {
// First turn: generate a context_id (the anchor for the multi-turn
// context; later sends use it to continue the conversation).
ctxID, err = store.createContext(p.agentID, truncateTitle(in.Text))
if err != nil {
return nil, err
}
}
// createTask validates context ownership while holding the lock (an unknown /
// cross-agent context id is rejected inside with a typed validation error),
// computes the round, and inserts atomically; the build callback only
// assembles the task body according to the round.
task, err := store.createTask(p.agentID, ctxID, func(round int) agent.AgentTask {
var reply string
switch entry.ID {
case "echo":
// Echo the input; from round 2 on, add a round marker to prove
// across commands that context memory really works.
reply = in.Text
if round > 1 {
reply = fmt.Sprintf("%s第 %d 轮)", in.Text, round)
}
default: // reporter
reply = "报表已生成quarterly_report.csv见 artifacts用 task get --artifact <id> -o <path> 下载)"
if n := len(in.Files); n > 0 {
reply = fmt.Sprintf("已收到 %d 个附件;%s", n, reply)
}
}
t := agent.AgentTask{
TaskID: newID("task"),
ContextID: ctxID,
State: agent.StateCompleted,
IsTerminal: true,
Messages: []agent.Message{
{Role: "user", Parts: []agent.Part{{Type: "text", Text: in.Text}}},
{Role: "agent", Parts: []agent.Part{{Type: "text", Text: reply}}},
},
}
if entry.ID == "reporter" {
// The artifact exposes only fields the provider can truly deliver
// (the contract.go rule: do not create empty shell fields that cannot
// be filled): the GetTask stage gives ID + Kind (a coarse-grained type
// hint), while the file name / mime are exposed at the
// DownloadArtifact stage as suggested_name.
t.Artifacts = []agent.Artifact{{ID: newID("art"), Kind: "text"}}
}
return t
})
if err != nil {
return nil, err
}
return &task, nil
}
// GetTask queries a single task's state and artifacts (reads the in-memory state machine).
func (p *exampleProvider) GetTask(ctx context.Context, taskID string) (*agent.AgentTask, error) {
if _, err := catalog.Lookup(p.agentID); err != nil {
return nil, err
}
task, err := store.getTask(p.agentID, taskID)
if err != nil {
return nil, err
}
return &task, nil
}
// ListTasks lists tasks, optionally filtered by contextID (empty string means no filter).
func (p *exampleProvider) ListTasks(ctx context.Context, contextID string) ([]agent.TaskSummary, error) {
if _, err := catalog.Lookup(p.agentID); err != nil {
return nil, err
}
return store.listTasks(p.agentID, contextID), nil
}
// CancelTask cancels a task. Both paths are demonstrated here:
// - echo (task_cancel=false): returns the agent.ErrUnsupported sentinel. Via
// the CLI this is unreachable (cmd/agent/task.go's card gating comes first),
// but tests calling the SPI directly / future callers that bypass the gate
// still get the correct semantics — belt-and-braces.
// - reporter (task_cancel=true): actually dispatched. But the mock task is
// completed the moment it is sent, so canceling a terminal task returns a
// failed_precondition typed error (state not satisfied, exit 2) rather than
// pretending success — honest error semantics matter as much as honest
// capability declaration.
func (p *exampleProvider) CancelTask(ctx context.Context, taskID string) error {
entry, err := catalog.Lookup(p.agentID)
if err != nil {
return err
}
if !entry.Capabilities.TaskCancel {
return agent.ErrUnsupported
}
task, err := store.getTask(p.agentID, taskID)
if err != nil {
return err
}
if task.State.IsTerminal() {
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"任务 '%s' 已处于终态 %s无法取消", taskID, task.State).
WithHint("终态任务不可取消;用 lark-cli agent task get example:%s %s 查看结果", p.agentID, taskID)
}
return store.setTaskState(taskID, agent.StateCanceled)
}
// ListContexts lists multi-turn contexts.
func (p *exampleProvider) ListContexts(ctx context.Context) ([]agent.ContextSummary, error) {
if _, err := catalog.Lookup(p.agentID); err != nil {
return nil, err
}
return store.listContexts(p.agentID), nil
}
// GetContext returns a single context's detail (including its task list).
func (p *exampleProvider) GetContext(ctx context.Context, ctxID string) (*agent.ContextDetail, error) {
if _, err := catalog.Lookup(p.agentID); err != nil {
return nil, err
}
return store.getContext(p.agentID, ctxID)
}
// DeleteContext deletes a context (a destructive operation; the --yes gate is in the command layer).
func (p *exampleProvider) DeleteContext(ctx context.Context, ctxID string) error {
if _, err := catalog.Lookup(p.agentID); err != nil {
return err
}
return store.deleteContext(p.agentID, ctxID)
}
// reportCSV is the fixed content of the reporter artifact (inline text, demonstrating a Bytes-type artifact).
const reportCSV = "quarter,revenue,cost,margin\n" +
"2026Q1,1250,830,0.336\n" +
"2026Q2,1410,905,0.358\n"
// DownloadArtifact fetches artifact data. example uses the inline Bytes type
// (the command layer writes it to disk directly); the URL type (a real
// provider's signed URL) fills the URL field, and SSRF validation plus the
// download are handled uniformly by the command layer.
//
// Teaching point (suggested_name): ArtifactData.Name is the "server-suggested
// file name", echoed back only as a suggested_name for the caller to reference
// when choosing -o — it is untrusted input and must never participate in
// constructing the local save path (the contract.go rule; the save path is
// always determined by -o/SafeOutputPath).
func (p *exampleProvider) DownloadArtifact(ctx context.Context, taskID, artifactID string) (*agent.ArtifactData, error) {
entry, err := catalog.Lookup(p.agentID)
if err != nil {
return nil, err
}
if !entry.Capabilities.ArtifactDownload {
// Keep behavior consistent with the card's artifact_download=false (the sentinel→typed chain).
return nil, fmt.Errorf("example:%s 不产出可下载产物: %w", p.agentID, agent.ErrUnsupported)
}
task, err := store.getTask(p.agentID, taskID)
if err != nil {
return nil, err
}
for _, a := range task.Artifacts {
if a.ID == artifactID {
return &agent.ArtifactData{
Name: "quarterly_report.csv",
Mime: "text/csv",
Bytes: []byte(reportCSV),
}, nil
}
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"任务 '%s' 名下没有产物 '%s'", taskID, artifactID).
WithHint("运行 lark-cli agent task get example:%s %s 查看该任务的 artifacts", p.agentID, taskID)
}
// truncateTitle takes the first few characters of the message as the
// conversation title (truncated by rune to avoid cutting a character in half).
func truncateTitle(s string) string {
const max = 20
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}

View File

@@ -0,0 +1,321 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package example
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/agent/agenttest"
)
// swapStore replaces the package-level store with an isolated instance pointing at
// t.TempDir, so tests do not pollute each other or the local demo snapshot.
func swapStore(t *testing.T) {
t.Helper()
old := store
store = newMemoryStore(filepath.Join(t.TempDir(), "state.json"))
t.Cleanup(func() { store = old })
}
// newProvider builds an example provider with zero-value Deps (the mock never needs a Client).
func newProvider(t *testing.T, agentID string) agent.Provider {
t.Helper()
p, err := newExampleProvider(agent.Deps{}, agentID)
if err != nil {
t.Fatalf("newExampleProvider: %v", err)
}
return p
}
// TestConformance runs the shared conformance suite: locking registration metadata,
// the zero-value Deps contract, the single-source Card, and catalog enumeration (the
// discovery group automatically verifies ListAgents contains example:echo and enumerates stably).
func TestConformance(t *testing.T) {
agenttest.RunConformance(t, scheme, "echo")
}
// TestConformanceReporter runs it again with reporter, so both catalog entries are locked by the contract.
func TestConformanceReporter(t *testing.T) {
agenttest.RunConformance(t, scheme, "reporter")
}
// TestCapabilityMatrixDiverges pins the deliberate difference between the two agents'
// capability matrices (the core of the teaching demo: honest capability declaration
// plus task_cancel true for one and false for the other).
func TestCapabilityMatrixDiverges(t *testing.T) {
echoCard, err := newProvider(t, "echo").Card(context.Background())
if err != nil {
t.Fatal(err)
}
reporterCard, err := newProvider(t, "reporter").Card(context.Background())
if err != nil {
t.Fatal(err)
}
ec, rc := echoCard.Capabilities, reporterCard.Capabilities
if ec.ArtifactDownload || ec.FileInput || ec.TaskCancel {
t.Errorf("echo should be the minimal capability 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 !(rc.ArtifactDownload && rc.FileInput && rc.TaskCancel && rc.InputRequired && rc.MultiTurn && rc.TaskGet && rc.TaskList) {
t.Errorf("reporter should have everything enabled, got %+v", rc)
}
}
// TestEchoMultiTurn verifies multi-turn context memory: the first turn echoes the
// original text and generates a context_id, and a follow-up in the same context
// echoes with a turn marker.
func TestEchoMultiTurn(t *testing.T) {
swapStore(t)
p := newProvider(t, "echo")
ctx := context.Background()
t1, err := p.Send(ctx, agent.SendInput{Text: "hello"})
if err != nil {
t.Fatalf("first-turn Send: %v", err)
}
if t1.State != agent.StateCompleted {
t.Fatalf("send should be immediately completed, got %s", t1.State)
}
if t1.ContextID == "" || t1.TaskID == "" {
t.Fatalf("first turn should generate context_id/task_id: %+v", t1)
}
if got := agentReply(t, t1); got != "hello" {
t.Fatalf("first-turn echo should be the original text, got %q", got)
}
t2, err := p.Send(ctx, agent.SendInput{Text: "再来", ContextID: t1.ContextID})
if err != nil {
t.Fatalf("follow-up Send: %v", err)
}
if t2.ContextID != t1.ContextID {
t.Fatalf("follow-up should stay in the same context: %q vs %q", t2.ContextID, t1.ContextID)
}
if got := agentReply(t, t2); got != "再来(第 2 轮)" {
t.Fatalf("second-turn echo should carry a turn marker, got %q", got)
}
// GetTask / ListTasks / ListContexts / GetContext read the same state machine.
got, err := p.GetTask(ctx, t2.TaskID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if agentReply(t, got) != "再来(第 2 轮)" {
t.Fatalf("GetTask should replay the stored messages, got %+v", got.Messages)
}
tasks, err := p.ListTasks(ctx, t1.ContextID)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 2 {
t.Fatalf("the same context should have 2 tasks, got %d", len(tasks))
}
ctxs, err := p.ListContexts(ctx)
if err != nil {
t.Fatal(err)
}
if len(ctxs) != 1 || ctxs[0].ContextID != t1.ContextID {
t.Fatalf("should have exactly 1 context with a matching id, got %+v", ctxs)
}
detail, err := p.GetContext(ctx, t1.ContextID)
if err != nil {
t.Fatal(err)
}
if len(detail.Tasks) != 2 {
t.Fatalf("context detail should contain 2 tasks, got %+v", detail)
}
}
// TestStateSurvivesReload pins the cross-process semantics: swapping in a new store
// instance pointing at the same snapshot file (simulating a new CLI process), the task
// is still queryable -- the offline demo chain depends on this.
func TestStateSurvivesReload(t *testing.T) {
swapStore(t)
p := newProvider(t, "echo")
task, err := p.Send(context.Background(), agent.SendInput{Text: "persist"})
if err != nil {
t.Fatal(err)
}
// A new store instance = a new process view; only the snapshot file is shared memory.
store = newMemoryStore(store.path)
got, err := p.GetTask(context.Background(), task.TaskID)
if err != nil {
t.Fatalf("GetTask after reload: %v", err)
}
if got.ContextID != task.ContextID {
t.Fatalf("task should replay fully after reload: %+v", got)
}
}
// TestReporterArtifactFlow verifies the full artifact chain: send produces {ID, Kind:text},
// and DownloadArtifact returns inline Bytes + suggested_name.
func TestReporterArtifactFlow(t *testing.T) {
swapStore(t)
p := newProvider(t, "reporter")
ctx := context.Background()
task, err := p.Send(ctx, agent.SendInput{Text: "本季度报表"})
if err != nil {
t.Fatal(err)
}
if len(task.Artifacts) != 1 {
t.Fatalf("reporter should produce 1 artifact, got %+v", task.Artifacts)
}
art := task.Artifacts[0]
if art.ID == "" || art.Kind != "text" {
t.Fatalf("artifact should carry ID + Kind=text, got %+v", art)
}
data, err := p.DownloadArtifact(ctx, task.TaskID, art.ID)
if err != nil {
t.Fatalf("DownloadArtifact: %v", err)
}
if data.Name != "quarterly_report.csv" {
t.Errorf("suggested_name should be quarterly_report.csv, got %q", data.Name)
}
if data.Mime != "text/csv" {
t.Errorf("mime should be text/csv, got %q", data.Mime)
}
if !strings.HasPrefix(string(data.Bytes), "quarter,revenue") {
t.Errorf("should return inline CSV bytes, got %q", string(data.Bytes))
}
// Unknown artifact id -> typed validation error.
if _, err := p.DownloadArtifact(ctx, task.TaskID, "art_nope"); err == nil {
t.Fatal("unknown artifact id should return an error")
} else if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("unknown artifact id should be a typed error, got %T: %v", err, err)
}
}
// TestEchoCancelSentinel verifies the sentinel chain: echo (task_cancel=false)
// returns agent.ErrUnsupported from CancelTask, which the command layer's
// convertUnsupported recognizes via errors.Is. The same holds for echo's
// DownloadArtifact and a Send with files.
func TestEchoCancelSentinel(t *testing.T) {
swapStore(t)
p := newProvider(t, "echo")
ctx := context.Background()
if err := p.CancelTask(ctx, "task_whatever"); !errors.Is(err, agent.ErrUnsupported) {
t.Fatalf("echo CancelTask should return the ErrUnsupported sentinel, got %v", err)
}
if _, err := p.DownloadArtifact(ctx, "task_x", "art_x"); !errors.Is(err, agent.ErrUnsupported) {
t.Fatalf("echo DownloadArtifact should return the ErrUnsupported sentinel, got %v", err)
}
if _, err := p.Send(ctx, agent.SendInput{Text: "hi", Files: []string{"a.txt"}}); !errors.Is(err, agent.ErrUnsupported) {
t.Fatalf("echo Send with --file should return the ErrUnsupported sentinel, got %v", err)
}
}
// TestReporterCancelTerminal verifies reporter supports cancel but returns a
// failed_precondition typed error for a terminal task (the mock task is completed
// as soon as it is sent).
func TestReporterCancelTerminal(t *testing.T) {
swapStore(t)
p := newProvider(t, "reporter")
ctx := context.Background()
task, err := p.Send(ctx, agent.SendInput{Text: "报表"})
if err != nil {
t.Fatal(err)
}
err = p.CancelTask(ctx, task.TaskID)
if err == nil {
t.Fatal("canceling a terminal task should return an error")
}
if errors.Is(err, agent.ErrUnsupported) {
t.Fatal("reporter supports cancel and should not return ErrUnsupported")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("terminal cancel should be a typed error, got %T: %v", err, err)
}
if prob.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("terminal cancel subtype should be failed_precondition, got %s", prob.Subtype)
}
}
// TestUnknownCatalogID verifies an unknown catalog id goes through StaticCatalog.Lookup's
// typed error (invalid_argument, with a hint pointing to agent list example).
func TestUnknownCatalogID(t *testing.T) {
swapStore(t)
p := newProvider(t, "nonexistent")
ctx := context.Background()
if _, err := p.Card(ctx); err == nil {
t.Fatal("Card with an unknown catalog id should return an error")
}
_, err := p.Send(ctx, agent.SendInput{Text: "hi"})
if err == nil {
t.Fatal("Send with an unknown catalog id should return an error")
}
prob, ok := errs.ProblemOf(err)
if !ok || prob.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("unknown catalog id should be an invalid_argument typed error, got %v", err)
}
}
// TestSendGuards pins Send's two typed rejections: --task-id follow-up (terminal
// semantics) and an unknown context id.
func TestSendGuards(t *testing.T) {
swapStore(t)
p := newProvider(t, "echo")
ctx := context.Background()
_, err := p.Send(ctx, agent.SendInput{Text: "hi", ContextID: "ctx_x", TaskID: "task_x"})
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("--task-id follow-up should be failed_precondition, got %v", err)
}
_, err = p.Send(ctx, agent.SendInput{Text: "hi", ContextID: "ctx_missing"})
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("unknown context id should be invalid_argument, got %v", err)
}
}
// TestDeleteContext verifies deleting a context also cleans up the tasks under it.
func TestDeleteContext(t *testing.T) {
swapStore(t)
p := newProvider(t, "echo")
ctx := context.Background()
task, err := p.Send(ctx, agent.SendInput{Text: "bye"})
if err != nil {
t.Fatal(err)
}
if err := p.DeleteContext(ctx, task.ContextID); err != nil {
t.Fatal(err)
}
if _, err := p.GetTask(ctx, task.TaskID); err == nil {
t.Fatal("after deleting the context its tasks should be unqueryable")
}
ctxs, err := p.ListContexts(ctx)
if err != nil {
t.Fatal(err)
}
if len(ctxs) != 0 {
t.Fatalf("no contexts should remain after deletion, got %+v", ctxs)
}
}
// agentReply returns the first text reply from the agent role in the task.
func agentReply(t *testing.T, task *agent.AgentTask) string {
t.Helper()
for _, m := range task.Messages {
if m.Role != "agent" {
continue
}
for _, part := range m.Parts {
if part.Type == "text" {
return part.Text
}
}
}
t.Fatalf("task is missing an agent text reply: %+v", task.Messages)
return ""
}

View File

@@ -0,0 +1,324 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package example
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/vfs"
)
// ============================================================================
// In-memory state machine (teaching focus: concurrency safety of package-level
// state + the CLI process boundary)
//
// A real provider's context/task state lives on the server, so the adapter is
// naturally stateless; example is a pure mock and must manage state itself. Two
// disciplines the integrator needs to know:
//
// 1. Concurrency safety: provider instances may be constructed / called
// concurrently (e.g. list's probe alongside the real call), so package-level
// mutable state must be locked. A single coarse-grained Mutex covers all
// reads and writes here — the mock does not chase throughput; correctness comes first.
// 2. CLI process boundary: every lark-cli command is a fresh process, so a pure
// in-memory map does not survive a single command — after `send`, a
// `task get` would find nothing. So a lazy JSON snapshot layer sits beneath
// the in-memory map (under os.TempDir, last-writer-wins) to make the offline
// demo chain work across commands. A real provider neither needs nor should
// have this layer — it is a mock-only demo device.
//
// Note that the snapshot is loaded lazily (only on the first real read/write of
// state): Register's zero-value Deps probe constructs a provider once at
// registration time, and construction must have no side effects (the registry.go
// contract), so Factory / Card / ListAgents must not touch store.
// ============================================================================
// taskRecord is a task's storage form: a full AgentTask snapshot + owning agent
// + creation sequence number (list output sorts by creation order to guarantee
// stable enumeration).
type taskRecord struct {
AgentID string `json:"agent_id"`
Seq int `json:"seq"`
Task agent.AgentTask `json:"task"`
}
// contextRecord is a multi-turn context's storage form. TaskIDs is appended in
// creation order — len(TaskIDs)+1 is the next round number, which echo uses to
// demonstrate "context memory".
type contextRecord struct {
AgentID string `json:"agent_id"`
ContextID string `json:"context_id"`
CreatedAt string `json:"created_at"`
Title string `json:"title,omitempty"`
Seq int `json:"seq"`
TaskIDs []string `json:"task_ids"`
}
// memoryStore is the package-level state machine itself: mu covers all fields;
// path is the JSON snapshot location; loaded ensures the snapshot is read only
// once, on first access.
type memoryStore struct {
mu sync.Mutex
path string
loaded bool
Contexts map[string]*contextRecord `json:"contexts"`
Tasks map[string]*taskRecord `json:"tasks"`
NextSeq int `json:"next_seq"`
}
// store is the package-level singleton. Tests use swapStoreForTest to replace it
// with an instance pointing at t.TempDir, avoiding cross-contamination between
// tests and between tests and the local demo state.
var store = newMemoryStore(filepath.Join(os.TempDir(), "lark-cli-example-agent.json"))
func newMemoryStore(path string) *memoryStore {
return &memoryStore{
path: path,
Contexts: map[string]*contextRecord{},
Tasks: map[string]*taskRecord{},
}
}
// loadLocked lazily reads in the snapshot (the caller must already hold the
// lock). A missing / corrupt snapshot is uniformly treated as empty state — the
// mock's demo data is not worth erroring over, so it just starts fresh.
func (s *memoryStore) loadLocked() {
if s.loaded {
return
}
s.loaded = true
data, err := vfs.ReadFile(s.path)
if err != nil {
return
}
var snap memoryStore
if json.Unmarshal(data, &snap) != nil {
return
}
if snap.Contexts != nil {
s.Contexts = snap.Contexts
}
if snap.Tasks != nil {
s.Tasks = snap.Tasks
}
s.NextSeq = snap.NextSeq
}
// saveLocked writes the current state back to the snapshot (the caller must
// already hold the lock). A write failure returns a typed internal error
// (storage subtype) — the mock does not swallow errors either: silently losing
// state would make the next command report "task not found", which is harder to
// diagnose than a clear error.
func (s *memoryStore) saveLocked() error {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "序列化 example 状态失败: %v", err).WithCause(err)
}
if err := vfs.WriteFile(s.path, data, 0o600); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "写 example 状态快照失败: %v", err).WithCause(err)
}
return nil
}
// newID generates a random id that is safe for [A-Za-z0-9_-]. The character set
// deliberately aligns with the command layer's meta.next interpolation
// allowlist (cmd/agent/send.go safeNextID): the id is spliced into a command
// string "the AI copies and runs", and an id with shell metacharacters would
// cause the whole hint to be suppressed.
func newID(prefix string) string {
var b [6]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand being unavailable is an environment-level failure; the mock
// degrades to a timestamp that still satisfies the character set.
return prefix + "_" + time.Now().UTC().Format("20060102150405")
}
return prefix + "_" + hex.EncodeToString(b[:])
}
// createContext creates a new context and returns its id (the first-turn send goes here).
func (s *memoryStore) createContext(agentID, title string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
id := newID("ctx")
s.NextSeq++
s.Contexts[id] = &contextRecord{
AgentID: agentID,
ContextID: id,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Title: title,
Seq: s.NextSeq,
}
return id, s.saveLocked()
}
// createTask appends a task under ctxID: validate context ownership → compute
// the round (which task number in this conversation) → call build under the lock
// to construct the task → insert and write the snapshot. build runs inside the
// lock to guarantee "compute the round" and "store the task" are atomic, so two
// concurrent sends never get the same round.
// An unknown / cross-agent context id returns a typed validation error (teaching
// point: every error a provider returns must be typed — a bare error would land
// as internal/exit 5, whereas this is clearly "the caller passed a wrong
// argument", semantically invalid_argument/exit 2, and the AI relies on this
// classification to decide between "fix the argument and retry" and "report an
// environment failure").
func (s *memoryStore) createTask(agentID, ctxID string, build func(round int) agent.AgentTask) (agent.AgentTask, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
ctx, ok := s.Contexts[ctxID]
if !ok || ctx.AgentID != agentID {
return agent.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 context id '%s'example:%s 名下不存在)", ctxID, agentID).
WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID)
}
task := build(len(ctx.TaskIDs) + 1)
s.NextSeq++
s.Tasks[task.TaskID] = &taskRecord{AgentID: agentID, Seq: s.NextSeq, Task: task}
ctx.TaskIDs = append(ctx.TaskIDs, task.TaskID)
return task, s.saveLocked()
}
// getTask fetches a task snapshot by id (returns a copy by value, so the command
// layer's in-place edits like normalizeTask do not write through to store). A
// cross-agent task is treated as "not found", without leaking another agent's state.
func (s *memoryStore) getTask(agentID, taskID string) (agent.AgentTask, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
rec, ok := s.Tasks[taskID]
if !ok || rec.AgentID != agentID {
return agent.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 task id '%s'example:%s 名下不存在)", taskID, agentID).
WithHint("运行 lark-cli agent task list example:%s 查看现有任务", agentID)
}
return rec.Task, nil
}
// setTaskState updates a task's state (used by reporter's cancel).
func (s *memoryStore) setTaskState(taskID string, state agent.TaskState) error {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
rec, ok := s.Tasks[taskID]
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "未知的 task id '%s'", taskID)
}
rec.Task.State = state
rec.Task.IsTerminal = state.IsTerminal()
return s.saveLocked()
}
// listTasks lists an agent's task summaries, optionally filtered by contextID
// (empty string means no filter), output in creation order. IsTerminal is
// carried along here for convenience, but the command layer re-derives it from
// State via normalizeTask* (single source), so the integrator need not worry
// about this field.
func (s *memoryStore) listTasks(agentID, contextID string) []agent.TaskSummary {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
recs := make([]*taskRecord, 0, len(s.Tasks))
for _, rec := range s.Tasks {
if rec.AgentID != agentID {
continue
}
if contextID != "" && rec.Task.ContextID != contextID {
continue
}
recs = append(recs, rec)
}
sort.Slice(recs, func(i, j int) bool { return recs[i].Seq < recs[j].Seq })
out := make([]agent.TaskSummary, 0, len(recs))
for _, rec := range recs {
out = append(out, agent.TaskSummary{
TaskID: rec.Task.TaskID,
ContextID: rec.Task.ContextID,
State: rec.Task.State,
IsTerminal: rec.Task.IsTerminal,
})
}
return out
}
// listContexts lists an agent's context summaries, output in creation order.
func (s *memoryStore) listContexts(agentID string) []agent.ContextSummary {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
recs := make([]*contextRecord, 0, len(s.Contexts))
for _, ctx := range s.Contexts {
if ctx.AgentID == agentID {
recs = append(recs, ctx)
}
}
sort.Slice(recs, func(i, j int) bool { return recs[i].Seq < recs[j].Seq })
out := make([]agent.ContextSummary, 0, len(recs))
for _, ctx := range recs {
out = append(out, agent.ContextSummary{
ContextID: ctx.ContextID,
CreatedAt: ctx.CreatedAt,
Title: ctx.Title,
})
}
return out
}
// getContext returns a context's detail (including its task summaries, in creation order).
func (s *memoryStore) getContext(agentID, ctxID string) (*agent.ContextDetail, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
ctx, ok := s.Contexts[ctxID]
if !ok || ctx.AgentID != agentID {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 context id '%s'example:%s 名下不存在)", ctxID, agentID).
WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID)
}
detail := &agent.ContextDetail{
ContextID: ctx.ContextID,
CreatedAt: ctx.CreatedAt,
Title: ctx.Title,
}
for _, tid := range ctx.TaskIDs {
if rec, ok := s.Tasks[tid]; ok {
detail.Tasks = append(detail.Tasks, agent.TaskSummary{
TaskID: rec.Task.TaskID,
ContextID: rec.Task.ContextID,
State: rec.Task.State,
IsTerminal: rec.Task.IsTerminal,
})
}
}
return detail, nil
}
// deleteContext deletes a context and its tasks (a destructive operation, already gated by --yes in the command layer).
func (s *memoryStore) deleteContext(agentID, ctxID string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.loadLocked()
ctx, ok := s.Contexts[ctxID]
if !ok || ctx.AgentID != agentID {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"未知的 context id '%s'example:%s 名下不存在)", ctxID, agentID).
WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID)
}
for _, tid := range ctx.TaskIDs {
delete(s.Tasks, tid)
}
delete(s.Contexts, ctxID)
return s.saveLocked()
}