Files
larksuite-cli/agents/example/example.go
liuxinyang.lxy a43c456ec1 feat: question-group answer scheme for input_required tasks
Replace the decision_id/--option HITL contract with the question-group
answer scheme:

- contract: InputRequired becomes {label, description, questions[]}
  (a single question is a length-1 group); Option gains description;
  decision_id/input_type/submitted fields are removed
- answering: --answer <qid>=<option_id> | <qid>.text=<text> is the only
  answer channel (--decision-id/--option removed); --text is always the
  message-level remark; offline collect-all key-grammar guards plus an
  input_required capability gate
- keys: minted at group-creation time (MintQuestionIDs /
  DeriveGroupSuffix), per-group-unique for stale-retry protection;
  central normalization with size caps and whole-group degradation on
  non-conforming keys (JSON _notice.provider_defect); KeyCharsetRE is
  the single charset source and safeNextID now requires an alphanumeric
  first character (rejects flag-lookalike ids)
- meta.next: per-question --answer templates with a relay-first label
- example/planner: three-question reference group; strict collect-all
  validation (Reason enum + question spec), atomic acceptance with a
  resolved_answers echo, context binding, no sibling-task fork on bare
  --text; Register enforces InputRequired => brand-covering CancelTask
- errs: ValidationError gains the resolved_answers extension field
- skills/lark-agents 1.3.0: relay-first rules, answer grammar, recovery
  playbook, untrusted question-text discipline
2026-07-21 15:38:28 +08:00

403 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 agents.Provider value: metadata + a catalog
// of agents.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/agents"
"github.com/larksuite/cli/internal/core"
)
// Provider is the whole declaration. The Catalog set makes this a catalog-type
// provider; the framework derives enumeration (agents list example), the
// unknown-id error, and each agent's card matrix from this data.
func Provider() agents.Provider {
return agents.Provider{
Scheme: "example",
Label: "Example 演示 agent内存 mock零网络",
AgentIDSource: "运行 lark-cli agents list example 查看内置演示 agent 及其 agent_ref无需任何平台配置",
Identities: []agents.IdentitySpec{{Type: agents.IdentityUser}, {Type: agents.IdentityBot}},
// RequiredScopes nil: the mock calls no OAPI, so scope preflight always passes.
Catalog: []agents.AgentSpec{echoSpec, reporterSpec, plannerSpec},
}
}
// 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 = agents.AgentSpec{
ID: "echo",
Name: "复读机",
Description: "把你发的话原样复读一遍(同一会话续发时带轮次,证明上下文记忆)。最小能力集示范。",
Send: agents.SendOp{Handler: echoSend},
GetTask: agents.TaskGetOp{Handler: getTask},
ListTasks: agents.TaskListOp{Handler: listTasks},
ListContexts: agents.ContextListOp{Handler: listContexts},
GetContext: agents.ContextGetOp{Handler: getContext},
DeleteContext: agents.ContextDeleteOp{Handler: 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.
// reporterSendParams is reporter's typed view of its send params — the
// BindParams copy-start template. agenttest.CheckParamsBinding locks the tags
// against the declaration below in example_test.go.
type reporterSendParams struct {
ReportFormat string `param:"report_format"`
Quarters int64 `param:"quarters"`
// Render binds the object param's leaves点路径/JSON 两通道归一后的
// "render.*" 键)——嵌套 struct + tag 即完成拼装。
Render renderOpts `param:"render"`
}
type renderOpts struct {
Theme string `param:"theme"`
Watermark bool `param:"watermark"`
}
var reporterSpec = agents.AgentSpec{
ID: "reporter",
Name: "报表生成器",
Description: "对任意请求产出一份内联 CSV 报表 artifact示范 artifact 下载与任务取消链路。",
FileInput: true,
// InputRequired is deliberately NOT declared: reporter never pauses (tasks
// are born terminal), and a question-asking flag would obligate an
// every-brand CancelTask (§6.8 registration check) — its CancelTask is the
// brand-scoping demo below. The HITL demo lives on planner.
// Send declares demo business params covering the whole declaration
// surface: enum + default (report_format), integer + min/max + default
// (quarters). Both optional with defaults, so a bare send behaves exactly
// like before — the params exist to be a copy-start template and to make
// the validation/card/meta.next chain exercisable offline.
Send: agents.SendOp{
Params: []agents.CardParam{
{Name: "report_format", Enum: []string{"csv", "xlsx"}, Default: "csv",
Desc: "报表输出格式"},
{Name: "quarters", Type: "integer", Min: agents.Float(1), Max: agents.Float(12), Default: "4",
Desc: "回溯季度数"},
// object 参数演示:点路径 --param render.theme=dark 或 JSON 整值
// --param render='{"theme":"dark"}' 两通道等价,框架归一后 hook 只见
// 平铺 "render.*" 键。
{Name: "render", Type: "object", Desc: "渲染选项", Fields: []agents.CardParam{
{Name: "theme", Enum: []string{"light", "dark"}, Default: "light", Desc: "配色主题"},
{Name: "watermark", Type: "boolean", Default: "false", Desc: "是否加水印"},
}},
},
Handler: reporterSend,
},
GetTask: agents.TaskGetOp{Handler: getTask},
ListTasks: agents.TaskListOp{Handler: listTasks},
ListContexts: agents.ContextListOp{Handler: listContexts},
GetContext: agents.ContextGetOp{Handler: getContext},
DeleteContext: agents.ContextDeleteOp{Handler: deleteContext},
// task_cancel is scoped to feishu — a real brand-scoped capability demo:
// under lark reporter's card shows task_cancel=false and
// `agents task cancel example:reporter` is gated with unavailable_for_brand
// (the whole agent stays visible under both brands — only this op is scoped).
CancelTask: agents.TaskCancelOp{Brands: []core.LarkBrand{core.BrandFeishu}, Handler: cancelTask},
DownloadArtifact: agents.ArtifactDownloadOp{Handler: downloadArtifact},
}
// plannerSpec demonstrates the input_required HITL flow (design doc §3-§8):
// the first send pauses on a THREE-question group (single-select + free-text +
// multi-select with a skip option), answered atomically in one send via
// --answer; a second submission gets failed_precondition + resolved_answers.
// It wires CancelTask because a question-asking agent must be walkaway-able
// (§6.8 — Register enforces this), and the read verbs; not artifact.
var plannerSpec = agents.AgentSpec{
ID: "planner",
Name: "报表规划器",
Description: "先弹一组确认问题(单选/自由文本/多选input_required你用 --answer 一次答清后再出报表。示范 HITL 问题组链路。",
InputRequired: true,
Send: agents.SendOp{Handler: plannerSend},
GetTask: agents.TaskGetOp{Handler: getTask},
ListTasks: agents.TaskListOp{Handler: listTasks},
CancelTask: agents.TaskCancelOp{Handler: cancelTask},
ListContexts: agents.ContextListOp{Handler: listContexts},
GetContext: agents.ContextGetOp{Handler: getContext},
DeleteContext: agents.ContextDeleteOp{Handler: deleteContext},
}
// plannerSend pauses a fresh request on a question group, or applies the
// --answer submission (continuing the group's own task). A bare --text aimed
// at the paused task is rejected with guidance — NEVER forked into a sibling
// task (§6.5): the group contains select questions, so free text cannot be
// consumed as the whole answer here.
func plannerSend(ctx context.Context, rt agents.Runtime, in agents.SendInput) (*agents.AgentTask, error) {
if len(in.Answers) > 0 {
if in.TaskID == "" {
// The CLI guard already enforces this; the belt holds for direct hook calls.
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"回答问题组需提供 --task-id").WithParam("--task-id")
}
task, err := store.answerGroup(rt.AgentID(), in.ContextID, in.TaskID, in.Answers, in.Text)
if err != nil {
return nil, err
}
return &task, nil
}
if in.TaskID != "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"该任务在等待问题组答复,且组内含选择题,无法用 --text 自由作答").
WithParam("--text").
WithHint("用 lark-cli agents task get example:%s %s 查看问题组,按 meta.next 的 --answer 模板作答", rt.AgentID(), in.TaskID)
}
ctxID := in.ContextID
if ctxID == "" {
var err error
ctxID, err = store.createContext(rt.AgentID(), truncateTitle(in.Text))
if err != nil {
return nil, err
}
}
// Mint the group's question ids at CREATION time with a fresh per-group
// suffix (§6.2): the group is persisted with these ids and every later
// task get echoes them verbatim; a successor group would mint a different
// suffix, which is the stale-retry protection.
questions := []agents.Question{
{Question: "按什么维度拆分?", Options: []agents.Option{
{OptionID: "by_region", Label: "按大区", Description: "华东/华北/华南汇总"},
{OptionID: "by_category", Label: "按品类", Description: "SKU 一级类目"},
}},
{Question: "时间范围?"},
{Question: "包含哪些区域?", MultiSelect: true, Options: []agents.Option{
{OptionID: "east", Label: "华东"},
{OptionID: "north", Label: "华北"},
{OptionID: "skip", Label: "由 agent 决定", Description: "与其它选项互斥"},
}},
}
agents.MintQuestionIDs(questions, newGroupSuffix())
task, err := store.createTask(rt.AgentID(), ctxID, func(int) agents.AgentTask {
return agents.AgentTask{
TaskID: newID("task"),
ContextID: ctxID,
State: agents.StateInputRequired,
Messages: []agents.Message{
{Role: "user", Parts: []agents.Part{{Type: "text", Text: in.Text}}},
{Role: "agent", Parts: []agents.Part{{Type: "text", Text: "生成报表前需确认以下口径。"}}},
},
InputRequired: &agents.InputRequired{
Label: "报表生成确认",
Description: "生成前需确认以下口径",
Questions: questions,
},
}
})
if err != nil {
return nil, err
}
return &task, nil
}
// ── 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 agents.Runtime, in agents.SendInput) (*agents.AgentTask, error) {
return newTurn(rt.AgentID(), in, func(round int) (string, []agents.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. It reads
// its demo params through BindParams — the typed, compile-checked consumption
// template (rt.Params() raw lookups work too but are typo-prone). With the
// declaration defaults (csv/4) the reply is byte-identical to the historical
// one; a hook invoked outside the framework (unit tests calling it directly)
// sees an empty param map and the same historical reply.
func reporterSend(ctx context.Context, rt agents.Runtime, in agents.SendInput) (*agents.AgentTask, error) {
p, err := agents.BindParams[reporterSendParams](rt)
if err != nil {
return nil, err
}
return newTurn(rt.AgentID(), in, func(round int) (string, []agents.Artifact) {
reply := "报表已生成quarterly_report.csv见 artifacts用 task get --artifact <id> -o <path> 下载)"
if p.ReportFormat != "" && p.ReportFormat != "csv" {
reply = fmt.Sprintf("报表已生成(%s 格式,回溯 %d 个季度quarterly_report.%s见 artifacts用 task get --artifact <id> -o <path> 下载)",
p.ReportFormat, p.Quarters, p.ReportFormat)
}
if p.Render.Watermark {
reply = fmt.Sprintf("%s%s 主题,含水印)", reply, p.Render.Theme)
}
if n := len(in.Files); n > 0 {
reply = fmt.Sprintf("已收到 %d 个附件;%s", n, reply)
}
// Name/Mime 在 GetTask 阶段就可见(下载前),调用方能直接据此定 -o 后缀,
// 不必先猜再靠下载后的 suggested_name 纠正——真实 provider 应尽量同样前置。
ext, mime := "csv", "text/csv"
if p.ReportFormat == "xlsx" {
ext, mime = "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
}
return reply, []agents.Artifact{{ID: newID("art"), Kind: "text", Name: "quarterly_report." + ext, Mime: mime}}
})
}
// 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 agents.SendInput, build func(round int) (reply string, artifacts []agents.Artifact)) (*agents.AgentTask, error) {
if len(in.Answers) > 0 {
// No pending question group exists on a born-terminal agent — reject
// loudly rather than silently dropping the answers (§6.4's no-silent-drop
// bottom line; reporter passes the CLI's input_required capability gate,
// so this is reachable there).
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"该 agent 没有待答的问题组").
WithParam("--answer").
WithHint("--answer 只用于回答停在 input_required 的任务;起新任务用 --text")
}
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-agents context id is rejected inside with a typed error), computes the
// round, and inserts atomically.
task, err := store.createTask(agentID, ctxID, func(round int) agents.AgentTask {
reply, artifacts := build(round)
return agents.AgentTask{
TaskID: newID("task"),
ContextID: ctxID,
State: agents.StateCompleted,
IsTerminal: true,
Messages: []agents.Message{
{Role: "user", Parts: []agents.Part{{Type: "text", Text: in.Text}}},
{Role: "agent", Parts: []agents.Part{{Type: "text", Text: reply}}},
},
Artifacts: artifacts,
}
})
if err != nil {
return nil, err
}
return &task, nil
}
func getTask(ctx context.Context, rt agents.Runtime, taskID string) (*agents.AgentTask, error) {
task, err := store.getTask(rt.AgentID(), taskID)
if err != nil {
return nil, err
}
return &task, nil
}
func listTasks(ctx context.Context, rt agents.Runtime, contextID string, page agents.PageParams) ([]agents.TaskSummary, agents.PageInfo, error) {
tasks, info := store.listTasks(rt.AgentID(), contextID, page)
return tasks, info, nil
}
func listContexts(ctx context.Context, rt agents.Runtime, page agents.PageParams) ([]agents.ContextSummary, agents.PageInfo, error) {
ctxs, info := store.listContexts(rt.AgentID(), page)
return ctxs, info, nil
}
func getContext(ctx context.Context, rt agents.Runtime, ctxID string) (*agents.ContextDetail, error) {
return store.getContext(rt.AgentID(), ctxID)
}
func deleteContext(ctx context.Context, rt agents.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 agents.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 agents task get example:%s %s 查看结果", rt.AgentID(), taskID)
}
return store.setTaskState(taskID, agents.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 agents.Runtime, taskID, artifactID string) (*agents.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 &agents.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 agents 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]) + "…"
}