mirror of
https://github.com/larksuite/cli.git
synced 2026-07-12 04:05:20 +08:00
Supersede the struct-of-func-fields Provider with a runtime-injection model that
stops leaking framework plumbing to integrators (the Deps{Client, As} an
onboarding author previously had to receive and destructure — the mock didn't
even use it).
Framework (internal/agent):
- Provider is now one declarative value per business domain (scheme): metadata +
a Catalog []AgentSpec (offline-enumerable) XOR an Instance *AgentSpec template,
plus an optional online ListAgents hook. AgentSpec carries per-agent card
metadata, the FileInput/InputRequired flags, and the verb hooks.
- Every hook receives an identity-opaque agent.Runtime (AgentID/IsBot/CallAPI/
CallMultipart) instead of a raw client; the concrete cmdRuntime lives in
cmd/agent (like event's consumeRuntime), so internal/agent no longer depends on
internal/client and the Deps struct is gone. CallMultipart is the centralized,
SafeInputPath-validated file-upload seam that makes file_input deliverable.
- Register takes a Provider (pure-struct validation, fail-fast). LookupSpec
resolves ref→spec fully offline. Capability = wired-hook presence
(DeriveCapabilities), card synthesized by BuildCard (rt=nil ⇒ offline caps +
static metadata; rt!=nil ⇒ best-effort Describe enrichment). Deleted catalog.go,
Deps, Factory, Resolve, NewCard, the zero-Deps probe, and the Discoverer interface.
Command layer (cmd/agent):
- resolveSpec (offline: identity + LookupSpec) then capability nil-gate BEFORE
runtimeFor, so an unsupported verb returns unsupported_capability (exit 2)
before any client is built — uniformly across list/context/artifact (previously
only cancel gated offline). Then runtimeFor + scope preflight + spec.<hook>(rt).
- agent list: catalog enumerates offline (ListCatalog); instance enumerates via
the online ListAgents hook, else reports not-enumerable.
Providers: example is now a declarative Provider() value + plain hooks reading
rt.AgentID() (echo minimal / reporter full differ only by wired fields). Explicit
aggregation in agent/register.go.
Adds cmd/agent runtime tests (CallAPI unwrap/error/transport, IsBot, CallMultipart
SafeInputPath), negative capability-gate tests (send --file / task list / context
get / artifact download), the https-only artifact check, and BuildCard's dynamic
Describe path.
234 lines
9.4 KiB
Go
234 lines
9.4 KiB
Go
// 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 package, rename the
|
||
// scheme, write plain hook funcs, add one line to agent/register.go. There is
|
||
// no Factory, no Deps, no probe, no Kind field.
|
||
// 2. The command tree's offline demo backend — the full agent
|
||
// list/card/send/task/context chain runs for real without any platform config.
|
||
// 3. A stable mock scheme for cmd-layer tests.
|
||
//
|
||
// The whole provider is a declarative agent.Provider value: metadata + a catalog
|
||
// of agent.AgentSpec units. Each spec's capability set is exactly the hooks it
|
||
// wires (the framework derives the card matrix from that), so echo (minimal) and
|
||
// reporter (full) differ by DATA, not by a Factory branch.
|
||
package example
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/internal/agent"
|
||
)
|
||
|
||
// Provider is the whole declaration. The Catalog set makes this a catalog-type
|
||
// provider; the framework derives enumeration (agent list example), the
|
||
// unknown-id error, and each agent's card matrix from this data.
|
||
func Provider() agent.Provider {
|
||
return agent.Provider{
|
||
Scheme: "example",
|
||
Label: "Example 演示 agent(内存 mock,零网络)",
|
||
AgentIDSource: "运行 lark-cli agent list example 查看内置演示 agent 及其 agent_ref(无需任何平台配置)",
|
||
Identities: []agent.IdentitySpec{{Type: agent.IdentityUser}, {Type: agent.IdentityBot}},
|
||
// RequiredScopes nil: the mock calls no OAPI, so scope preflight always passes.
|
||
Catalog: []agent.AgentSpec{echoSpec, reporterSpec},
|
||
}
|
||
}
|
||
|
||
// echoSpec is the minimal set: it wires Send/GetTask plus the read verbs and
|
||
// NOTHING else, so its card honestly shows task_cancel / artifact_download /
|
||
// file_input = false. Capability IS exactly the wired hooks — there is no bool
|
||
// matrix and no capability-refusal code (the command layer gates unwired hooks).
|
||
var echoSpec = agent.AgentSpec{
|
||
ID: "echo",
|
||
Name: "复读机",
|
||
Description: "把你发的话原样复读一遍(同一会话续发时带轮次,证明上下文记忆)。最小能力集示范。",
|
||
Send: echoSend,
|
||
GetTask: getTask,
|
||
ListTasks: listTasks,
|
||
ListContexts: listContexts,
|
||
GetContext: getContext,
|
||
DeleteContext: deleteContext,
|
||
}
|
||
|
||
// reporterSpec is the full set: it additionally wires CancelTask +
|
||
// DownloadArtifact and declares the FileInput/InputRequired behavioral flags. The
|
||
// difference between the two agents is data you read top-to-bottom, not a branch
|
||
// inside a Factory.
|
||
var reporterSpec = agent.AgentSpec{
|
||
ID: "reporter",
|
||
Name: "报表生成器",
|
||
Description: "对任意请求产出一份内联 CSV 报表 artifact,示范 artifact 下载与任务取消链路。",
|
||
FileInput: true,
|
||
InputRequired: true,
|
||
Send: reporterSend,
|
||
GetTask: getTask,
|
||
ListTasks: listTasks,
|
||
ListContexts: listContexts,
|
||
GetContext: getContext,
|
||
DeleteContext: deleteContext,
|
||
CancelTask: cancelTask,
|
||
DownloadArtifact: downloadArtifact,
|
||
}
|
||
|
||
// ── Hooks: plain funcs. The addressed agent comes from rt.AgentID() (request
|
||
// data, replacing the old state.agentID). The mock ignores rt's network
|
||
// methods (CallAPI/CallMultipart/IsBot). There is NO catalog.Lookup guard
|
||
// anywhere — the framework's LookupSpec validated ref→spec offline before
|
||
// dispatch, so an unknown id never reaches a hook. ──
|
||
|
||
// echoSend echoes the input; from round 2 on it appends a round marker to prove
|
||
// across commands that context memory works.
|
||
func echoSend(ctx context.Context, rt agent.Runtime, in agent.SendInput) (*agent.AgentTask, error) {
|
||
return newTurn(rt.AgentID(), in, func(round int) (string, []agent.Artifact) {
|
||
reply := in.Text
|
||
if round > 1 {
|
||
reply = fmt.Sprintf("%s(第 %d 轮)", in.Text, round)
|
||
}
|
||
return reply, nil
|
||
})
|
||
}
|
||
|
||
// reporterSend produces a fixed inline CSV artifact for any request.
|
||
func reporterSend(ctx context.Context, rt agent.Runtime, in agent.SendInput) (*agent.AgentTask, error) {
|
||
return newTurn(rt.AgentID(), in, func(round int) (string, []agent.Artifact) {
|
||
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)
|
||
}
|
||
return reply, []agent.Artifact{{ID: newID("art"), Kind: "text"}}
|
||
})
|
||
}
|
||
|
||
// newTurn factors the shared store flow: start/continue a context, then create a
|
||
// task whose body the caller builds per round. 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 is valid but the target
|
||
// state does not satisfy it, so the AI knows to start a new task instead).
|
||
func newTurn(agentID string, in agent.SendInput, build func(round int) (reply string, artifacts []agent.Artifact)) (*agent.AgentTask, error) {
|
||
if in.TaskID != "" {
|
||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||
"example 的任务发出即完成(终态),无法向已有任务续发").
|
||
WithParam("--task-id").
|
||
WithHint("去掉 --task-id,用 --context-id 在同一会话起新一轮任务")
|
||
}
|
||
ctxID := in.ContextID
|
||
if ctxID == "" {
|
||
var err error
|
||
ctxID, err = store.createContext(agentID, truncateTitle(in.Text))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
// createTask validates context ownership under the lock (an unknown /
|
||
// cross-agent context id is rejected inside with a typed error), computes the
|
||
// round, and inserts atomically.
|
||
task, err := store.createTask(agentID, ctxID, func(round int) agent.AgentTask {
|
||
reply, artifacts := build(round)
|
||
return 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}}},
|
||
},
|
||
Artifacts: artifacts,
|
||
}
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &task, nil
|
||
}
|
||
|
||
func getTask(ctx context.Context, rt agent.Runtime, taskID string) (*agent.AgentTask, error) {
|
||
task, err := store.getTask(rt.AgentID(), taskID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &task, nil
|
||
}
|
||
|
||
func listTasks(ctx context.Context, rt agent.Runtime, contextID string) ([]agent.TaskSummary, error) {
|
||
return store.listTasks(rt.AgentID(), contextID), nil
|
||
}
|
||
|
||
func listContexts(ctx context.Context, rt agent.Runtime) ([]agent.ContextSummary, error) {
|
||
return store.listContexts(rt.AgentID()), nil
|
||
}
|
||
|
||
func getContext(ctx context.Context, rt agent.Runtime, ctxID string) (*agent.ContextDetail, error) {
|
||
return store.getContext(rt.AgentID(), ctxID)
|
||
}
|
||
|
||
func deleteContext(ctx context.Context, rt agent.Runtime, ctxID string) error {
|
||
return store.deleteContext(rt.AgentID(), ctxID)
|
||
}
|
||
|
||
// cancelTask is wired only for reporter, so echo never reaches it (the command
|
||
// layer gates echo's cancel on the nil field). The mock task is completed the
|
||
// moment it is sent, so canceling a terminal task returns a failed_precondition
|
||
// typed error rather than pretending success.
|
||
func cancelTask(ctx context.Context, rt agent.Runtime, taskID string) error {
|
||
task, err := store.getTask(rt.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 查看结果", rt.AgentID(), taskID)
|
||
}
|
||
return store.setTaskState(taskID, agent.StateCanceled)
|
||
}
|
||
|
||
// reportCSV is the fixed content of the reporter artifact (inline Bytes type).
|
||
const reportCSV = "quarter,revenue,cost,margin\n" +
|
||
"2026Q1,1250,830,0.336\n" +
|
||
"2026Q2,1410,905,0.358\n"
|
||
|
||
// downloadArtifact is wired only for reporter (echo is gated on the nil field).
|
||
// It returns inline Bytes; a real provider would fill URL instead and let the
|
||
// command layer SSRF-validate + fetch.
|
||
//
|
||
// Teaching point (suggested_name): ArtifactData.Name is the server-suggested
|
||
// file name, echoed back only as a reference for choosing -o — it is untrusted
|
||
// and never participates in constructing the local save path (the save path is
|
||
// always -o/SafeOutputPath).
|
||
func downloadArtifact(ctx context.Context, rt agent.Runtime, taskID, artifactID string) (*agent.ArtifactData, error) {
|
||
task, err := store.getTask(rt.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", rt.AgentID(), taskID)
|
||
}
|
||
|
||
// truncateTitle takes the first few characters of the message as the context
|
||
// 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]) + "…"
|
||
}
|