From 9efd859fb50cb43af809530cc45cef7bf2d76bca Mon Sep 17 00:00:00 2001 From: Xiangyang Hu Date: Mon, 13 Jul 2026 22:26:02 +0800 Subject: [PATCH] feat(agents): add Base assistant provider Register the fixed base:assistant catalog provider and adapt Base Agent responses into the shared agent contract. Cover request construction, task and context mapping, state handling, idempotency, and provider documentation. --- agents/base/adapter_client.go | 193 +++++++++++ agents/base/base.go | 129 ++++++++ agents/base/base_test.go | 307 ++++++++++++++++++ agents/base/dto.go | 72 ++++ agents/base/mapper.go | 305 +++++++++++++++++ agents/register.go | 2 + skills/lark-agents/SKILL.md | 1 + .../references/providers/lark-agents-base.md | 77 +++++ 8 files changed, 1086 insertions(+) create mode 100644 agents/base/adapter_client.go create mode 100644 agents/base/base.go create mode 100644 agents/base/base_test.go create mode 100644 agents/base/dto.go create mode 100644 agents/base/mapper.go create mode 100644 skills/lark-agents/references/providers/lark-agents-base.md diff --git a/agents/base/adapter_client.go b/agents/base/adapter_client.go new file mode 100644 index 000000000..8e0d93389 --- /dev/null +++ b/agents/base/adapter_client.go @@ -0,0 +1,193 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/url" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + iagents "github.com/larksuite/cli/internal/agents" +) + +func callPayload[T any](ctx context.Context, rt iagents.Runtime, method, path string, query map[string]string, body any) (T, error) { + payload, err := iagents.Call[string](ctx, rt, method, path, query, body) + if err != nil { + var zero T + return zero, err + } + if strings.TrimSpace(payload) == "" { + var zero T + return zero, errs.NewInternalError(errs.SubtypeInvalidResponse, + "Base Adapter returned an empty payload for %s %s", method, path) + } + var out T + if err := json.Unmarshal([]byte(payload), &out); err != nil { + return out, errs.NewInternalError(errs.SubtypeInvalidResponse, + "decode Base Adapter payload for %s %s: %v", method, path, err).WithCause(err) + } + return out, nil +} + +func segment(v string) string { return url.PathEscape(v) } + +func agentRoot(baseToken string) string { + return "/bases/" + segment(baseToken) + "/ai/agents/" + segment(adapterAgentID) +} + +func randomIdempotencyKey() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", errs.NewInternalError(errs.SubtypeUnknown, + "generate Base Agent idempotency key: %v", err).WithCause(err) + } + return "lark-cli-" + hex.EncodeToString(b[:]), nil +} + +func sendMessage(ctx context.Context, rt iagents.Runtime, in iagents.SendInput) (*iagents.AgentTask, error) { + p, err := iagents.BindParams[sendParams](rt) + if err != nil { + return nil, err + } + key, err := randomIdempotencyKey() + if err != nil { + return nil, err + } + params := map[string]string{} + if p.ActiveTableID != "" { + params["active_table_id"] = p.ActiveTableID + } + req := adapterSendRequest{ + ContextID: in.ContextID, + TaskID: in.TaskID, + Message: adapterMessage{Role: "user", Parts: []adapterPart{{Type: "text", Text: in.Text}}}, + Params: params, + IdempotencyKey: key, + Metadata: map[string]string{"channel": "lark_cli"}, + } + path := agentRoot(p.BaseToken) + "/messages" + got, err := callPayload[adapterTask](ctx, rt, "POST", path, nil, req) + if err != nil { + return nil, err + } + return mapTask(got, true) +} + +func getTask(ctx context.Context, rt iagents.Runtime, taskID string) (*iagents.AgentTask, error) { + p, err := iagents.BindParams[baseTokenParams](rt) + if err != nil { + return nil, err + } + path := agentRoot(p.BaseToken) + "/tasks/" + segment(taskID) + got, err := callPayload[adapterTask](ctx, rt, "GET", path, nil, nil) + if err != nil { + return nil, err + } + return mapTask(got, false) +} + +func listTasks(ctx context.Context, rt iagents.Runtime, contextID string) ([]iagents.TaskSummary, error) { + p, err := iagents.BindParams[listTasksParams](rt) + if err != nil { + return nil, err + } + query := map[string]string{} + putQuery(query, "context_id", contextID) + putQuery(query, "cursor", p.Cursor) + if p.Limit > 0 { + query["limit"] = strconv.FormatInt(p.Limit, 10) + } + putQuery(query, "state", p.State) + got, err := callPayload[[]adapterTask](ctx, rt, "GET", agentRoot(p.BaseToken)+"/tasks", query, nil) + if err != nil { + return nil, err + } + out := make([]iagents.TaskSummary, 0, len(got)) + for _, item := range got { + summary, err := mapTaskSummary(item) + if err != nil { + return nil, err + } + out = append(out, summary) + } + return out, nil +} + +func cancelTask(ctx context.Context, rt iagents.Runtime, taskID string) error { + p, err := iagents.BindParams[baseTokenParams](rt) + if err != nil { + return err + } + path := agentRoot(p.BaseToken) + "/tasks/" + segment(taskID) + "/cancel" + result, err := callPayload[adapterResult](ctx, rt, "POST", path, nil, map[string]any{ + "metadata": map[string]string{"channel": "lark_cli"}, + }) + if err != nil { + return err + } + return mapResult(result, "cancel task") +} + +func listContexts(ctx context.Context, rt iagents.Runtime) ([]iagents.ContextSummary, error) { + p, err := iagents.BindParams[listContextsParams](rt) + if err != nil { + return nil, err + } + query := map[string]string{} + putQuery(query, "cursor", p.Cursor) + if p.Limit > 0 { + query["limit"] = strconv.FormatInt(p.Limit, 10) + } + putQuery(query, "status", p.Status) + got, err := callPayload[[]adapterContext](ctx, rt, "GET", agentRoot(p.BaseToken)+"/contexts", query, nil) + if err != nil { + return nil, err + } + out := make([]iagents.ContextSummary, 0, len(got)) + for _, item := range got { + mapped, err := mapContextSummary(item) + if err != nil { + return nil, err + } + out = append(out, mapped) + } + return out, nil +} + +func getContext(ctx context.Context, rt iagents.Runtime, contextID string) (*iagents.ContextDetail, error) { + p, err := iagents.BindParams[baseTokenParams](rt) + if err != nil { + return nil, err + } + path := agentRoot(p.BaseToken) + "/contexts/" + segment(contextID) + got, err := callPayload[adapterContext](ctx, rt, "GET", path, nil, nil) + if err != nil { + return nil, err + } + return mapContextDetail(got) +} + +func deleteContext(ctx context.Context, rt iagents.Runtime, contextID string) error { + p, err := iagents.BindParams[baseTokenParams](rt) + if err != nil { + return err + } + path := agentRoot(p.BaseToken) + "/contexts/" + segment(contextID) + result, err := callPayload[adapterResult](ctx, rt, "DELETE", path, nil, nil) + if err != nil { + return err + } + return mapResult(result, "delete context") +} + +func putQuery(query map[string]string, key, value string) { + if value != "" { + query[key] = value + } +} diff --git a/agents/base/base.go b/agents/base/base.go new file mode 100644 index 000000000..79b2eed76 --- /dev/null +++ b/agents/base/base.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package base exposes the fixed Base assistant through the provider-neutral +// agents SPI. Adapter-specific wire details intentionally stay in this package. +package base + +import ( + "context" + + "github.com/larksuite/cli/errs" + iagents "github.com/larksuite/cli/internal/agents" +) + +// baseAgentScope is the dedicated Base Agent scope name reserved by the +// provider contract. The Open Platform owner must provision this scope before +// real-account preflight can succeed. +const baseAgentScope = "base:agent:execute" + +// adapterAgentID is deliberately private: callers always use base:assistant, +// and a future Adapter-side ID change is isolated to this mapping. +const adapterAgentID = "assistant" + +type sendParams struct { + BaseToken string `param:"base_token"` + ActiveTableID string `param:"active_table_id"` +} + +type baseTokenParams struct { + BaseToken string `param:"base_token"` +} + +type listTasksParams struct { + BaseToken string `param:"base_token"` + Cursor string `param:"cursor"` + Limit int64 `param:"limit"` + State string `param:"state"` +} + +type listContextsParams struct { + BaseToken string `param:"base_token"` + Cursor string `param:"cursor"` + Limit int64 `param:"limit"` + Status string `param:"status"` +} + +func baseTokenParam() []iagents.CardParam { + return []iagents.CardParam{{Name: "base_token", Required: true, Desc: "Base app token"}} +} + +func sendParamList() []iagents.CardParam { + return []iagents.CardParam{ + {Name: "base_token", Required: true, Desc: "Base app token"}, + {Name: "active_table_id", Desc: "Optional active table for automatic routing"}, + } +} + +func listTasksParamList() []iagents.CardParam { + return []iagents.CardParam{ + {Name: "base_token", Required: true, Desc: "Base app token"}, + {Name: "cursor", Desc: "Adapter page cursor"}, + {Name: "limit", Type: "integer", Min: iagents.Float(1), Max: iagents.Float(100), Desc: "Page size"}, + {Name: "state", Enum: []string{"running", "done", "failed"}, Desc: "Adapter task state"}, + } +} + +func listContextsParamList() []iagents.CardParam { + return []iagents.CardParam{ + {Name: "base_token", Required: true, Desc: "Base app token"}, + {Name: "cursor", Desc: "Adapter page cursor"}, + {Name: "limit", Type: "integer", Min: iagents.Float(1), Max: iagents.Float(100), Desc: "Page size"}, + {Name: "status", Desc: "Adapter context status"}, + } +} + +var assistantSpec = iagents.AgentSpec{ + ID: "assistant", + Name: "Base Assistant", + Description: "Automatically routes Base creation, dashboard, workflow, and data questions to the appropriate capability.", + Skills: []iagents.CardSkill{ + {ID: "base_build", Name: "Build a Base", Examples: []string{"Create a project tracker with owners and due dates"}}, + {ID: "base_analyze", Name: "Analyze Base data", Examples: []string{"Summarize this table and highlight anomalies"}}, + }, + Send: iagents.SendOp{Params: sendParamList(), Handler: send}, + GetTask: iagents.TaskGetOp{Params: baseTokenParam(), Handler: getTask}, + ListTasks: iagents.TaskListOp{Params: listTasksParamList(), Handler: listTasks}, + CancelTask: iagents.TaskCancelOp{Params: baseTokenParam(), Handler: cancelTask}, + ListContexts: iagents.ContextListOp{Params: listContextsParamList(), Handler: listContexts}, + GetContext: iagents.ContextGetOp{Params: baseTokenParam(), Handler: getContext}, + DeleteContext: iagents.ContextDeleteOp{Params: baseTokenParam(), Handler: deleteContext}, + FileInput: false, + InputRequired: false, +} + +// Provider returns the single offline-discoverable Base assistant. +func Provider() iagents.Provider { + return iagents.Provider{ + Scheme: "base", + Label: "Base Assistant", + AgentIDSource: "Use the fixed agent reference base:assistant", + RequiredScopes: []string{baseAgentScope}, + Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}}, + Catalog: []iagents.AgentSpec{assistantSpec}, + } +} + +func validateSendRuntime(rt iagents.Runtime, in iagents.SendInput) error { + if rt.IsBot() { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "base:assistant currently supports only user identity").WithParam("--as"). + WithHint("run the command with --as user") + } + if len(in.Files) > 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "base:assistant does not support file input").WithParam("--file") + } + if in.DecisionID != "" || len(in.OptionIDs) > 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "base:assistant does not support structured input_required answers").WithParam("--decision-id") + } + return nil +} + +func send(ctx context.Context, rt iagents.Runtime, in iagents.SendInput) (*iagents.AgentTask, error) { + if err := validateSendRuntime(rt, in); err != nil { + return nil, err + } + return sendMessage(ctx, rt, in) +} diff --git a/agents/base/base_test.go b/agents/base/base_test.go new file mode 100644 index 000000000..9dcb6f1be --- /dev/null +++ b/agents/base/base_test.go @@ -0,0 +1,307 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + iagents "github.com/larksuite/cli/internal/agents" + "github.com/larksuite/cli/internal/agents/agenttest" +) + +func init() { iagents.Register(Provider()) } + +type apiCall struct { + method string + path string + query map[string]string + body any +} + +type fakeRuntime struct { + agentID string + bot bool + params map[string]string + responses []json.RawMessage + errs []error + calls []apiCall +} + +func (f *fakeRuntime) AgentID() string { return f.agentID } +func (f *fakeRuntime) IsBot() bool { return f.bot } +func (f *fakeRuntime) Params() map[string]string { return f.params } +func (f *fakeRuntime) CallMultipart(context.Context, string, string, map[string]string, []iagents.FilePart) (json.RawMessage, error) { + panic("base provider must not upload files") +} +func (f *fakeRuntime) CallAPI(_ context.Context, method, path string, query map[string]string, body any) (json.RawMessage, error) { + q := make(map[string]string, len(query)) + for k, v := range query { + q[k] = v + } + f.calls = append(f.calls, apiCall{method: method, path: path, query: q, body: body}) + if len(f.errs) > 0 { + err := f.errs[0] + f.errs = f.errs[1:] + if err != nil { + return nil, err + } + } + if len(f.responses) == 0 { + return nil, nil + } + raw := f.responses[0] + f.responses = f.responses[1:] + return raw, nil +} + +func payloadResponse(t *testing.T, payload string) json.RawMessage { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + return raw +} + +func bodyMap(t *testing.T, body any) map[string]any { + t.Helper() + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out +} + +func problem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) { + t.Helper() + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want typed error, got %T: %v", err, err) + } + if p.Category != category || p.Subtype != subtype { + t.Fatalf("problem=(%s,%s), want (%s,%s)", p.Category, p.Subtype, category, subtype) + } +} + +func TestProviderConformance(t *testing.T) { + agenttest.RunConformance(t, "base", "assistant") + p := Provider() + if !reflect.DeepEqual(p.RequiredScopes, []string{baseAgentScope}) { + t.Fatalf("RequiredScopes=%v", p.RequiredScopes) + } + if len(p.Catalog) != 1 || p.Catalog[0].ID != "assistant" { + t.Fatalf("catalog=%+v", p.Catalog) + } + caps := iagents.DeriveCapabilities(&p.Catalog[0]) + if !caps.TaskGet || !caps.TaskList || !caps.TaskCancel || !caps.ContextList || !caps.ContextGet || !caps.ContextDelete { + t.Fatalf("missing required capability: %+v", caps) + } + if caps.FileInput || caps.InputRequired || caps.ArtifactDownload { + t.Fatalf("unsupported capability advertised: %+v", caps) + } + agenttest.CheckParamsBinding[sendParams](t, &p.Catalog[0], iagents.VerbSend) + agenttest.CheckParamsBinding[listTasksParams](t, &p.Catalog[0], iagents.VerbTaskList) + agenttest.CheckParamsBinding[listContextsParams](t, &p.Catalog[0], iagents.VerbContextList) +} + +func TestSendBuildsAdapterRequest(t *testing.T) { + rt := &fakeRuntime{ + agentID: "assistant", + params: map[string]string{"base_token": "basc/token", "active_table_id": "tbl1"}, + responses: []json.RawMessage{payloadResponse(t, `{"task_id":"task-1","context_id":"ctx-1"}`)}, + } + task, err := assistantSpec.Send.Handler(context.Background(), rt, iagents.SendInput{Text: "build a dashboard"}) + if err != nil { + t.Fatal(err) + } + if task.TaskID != "task-1" || task.ContextID != "ctx-1" || task.State != iagents.StateSubmitted || task.IsTerminal { + t.Fatalf("task=%+v", task) + } + if len(rt.calls) != 1 { + t.Fatalf("calls=%d", len(rt.calls)) + } + call := rt.calls[0] + if call.method != "POST" || call.path != "/bases/basc%2Ftoken/ai/agents/assistant/messages" { + t.Fatalf("call=%s %s", call.method, call.path) + } + body := bodyMap(t, call.body) + if body["context_id"] != nil || body["task_id"] != nil { + t.Fatalf("fresh send must omit context/task: %#v", body) + } + if body["idempotency_key"] == "" || body["idempotency_key"] == nil { + t.Fatalf("missing idempotency key: %#v", body) + } + params := body["params"].(map[string]any) + if params["active_table_id"] != "tbl1" { + t.Fatalf("params=%#v", params) + } + metadata := body["metadata"].(map[string]any) + if metadata["channel"] != "lark_cli" { + t.Fatalf("metadata=%#v", metadata) + } + encoded, _ := json.Marshal(body) + for _, forbidden := range []string{"preJobId", "skill_id", "action_code", "scene_code", "sidebar_tools", "memberId", "UserID", "TenantID", "AppID"} { + if strings.Contains(string(encoded), forbidden) { + t.Fatalf("request leaked forbidden field %q: %s", forbidden, encoded) + } + } +} + +func TestSendContinuationAndIdempotency(t *testing.T) { + rt := &fakeRuntime{ + agentID: "assistant", + params: map[string]string{"base_token": "b1"}, + responses: []json.RawMessage{ + payloadResponse(t, `{"task_id":"t1","context_id":"c1","state":"running"}`), + payloadResponse(t, `{"task_id":"t1","context_id":"c1","state":"running"}`), + }, + } + in := iagents.SendInput{Text: "more", ContextID: "c1", TaskID: "t1"} + if _, err := assistantSpec.Send.Handler(context.Background(), rt, in); err != nil { + t.Fatal(err) + } + if _, err := assistantSpec.Send.Handler(context.Background(), rt, in); err != nil { + t.Fatal(err) + } + b1, b2 := bodyMap(t, rt.calls[0].body), bodyMap(t, rt.calls[1].body) + if b1["context_id"] != "c1" || b1["task_id"] != "t1" { + t.Fatalf("continuation body=%#v", b1) + } + if b1["idempotency_key"] == b2["idempotency_key"] { + t.Fatalf("logical sends reused key %q", b1["idempotency_key"]) + } +} + +func TestSendRejectsDisabledInputs(t *testing.T) { + tests := []iagents.SendInput{ + {Text: "x", Files: []string{"a.txt"}}, + {Text: "x", DecisionID: "d1"}, + {Text: "x", OptionIDs: []string{"o1"}}, + } + for _, in := range tests { + rt := &fakeRuntime{params: map[string]string{"base_token": "b1"}} + _, err := assistantSpec.Send.Handler(context.Background(), rt, in) + problem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument) + if len(rt.calls) != 0 { + t.Fatalf("disabled input made API call: %+v", in) + } + } +} + +func TestSendRejectsBotIdentity(t *testing.T) { + rt := &fakeRuntime{bot: true, params: map[string]string{"base_token": "b1"}} + _, err := assistantSpec.Send.Handler(context.Background(), rt, iagents.SendInput{Text: "x"}) + problem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument) + if len(rt.calls) != 0 { + t.Fatal("bot identity must be rejected before an API call") + } +} + +func TestTaskHooksAndMapping(t *testing.T) { + rt := &fakeRuntime{ + params: map[string]string{"base_token": "b1", "cursor": "next", "limit": "20", "state": "running"}, + responses: []json.RawMessage{ + payloadResponse(t, `{"task_id":"t/1","context_id":"c1","state":"running","created_at":1710000000,"updated_at":1710000060,"messages":[{"role":"agent","parts":[{"type":"data","text":"{\"operation_type\":\"text\",\"content\":\"ready\"}"}]}]}`), + payloadResponse(t, `[{"task_id":"t1","context_id":"c1","state":"done","updated_at":1710000060,"summary":"done"}]`), + }, + } + task, err := assistantSpec.GetTask.Handler(context.Background(), rt, "t/1") + if err != nil { + t.Fatal(err) + } + if task.State != iagents.StateWorking || task.IsTerminal || task.Messages[0].Parts[0].Text != "ready" { + t.Fatalf("task=%+v", task) + } + if rt.calls[0].path != "/bases/b1/ai/agents/assistant/tasks/t%2F1" { + t.Fatalf("path=%s", rt.calls[0].path) + } + list, err := assistantSpec.ListTasks.Handler(context.Background(), rt, "c1") + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].State != iagents.StateCompleted || !list[0].IsTerminal { + t.Fatalf("list=%+v", list) + } + wantQuery := map[string]string{"context_id": "c1", "cursor": "next", "limit": "20", "state": "running"} + if !reflect.DeepEqual(rt.calls[1].query, wantQuery) { + t.Fatalf("query=%v want %v", rt.calls[1].query, wantQuery) + } +} + +func TestUnknownStateAndInvalidPayloadAreTyped(t *testing.T) { + for _, payload := range []string{`{"task_id":"t1","state":"paused"}`, `{not-json`} { + rt := &fakeRuntime{params: map[string]string{"base_token": "b1"}, responses: []json.RawMessage{payloadResponse(t, payload)}} + _, err := assistantSpec.GetTask.Handler(context.Background(), rt, "t1") + problem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse) + } +} + +func TestEmbeddedCliMessageMapping(t *testing.T) { + t.Run("unknown operation stays data", func(t *testing.T) { + part, err := mapPart(adapterPart{Type: "data", Text: `{"operation_type":"run_shell","content":"rm -rf /"}`}) + if err != nil { + t.Fatal(err) + } + if part.Type != "data" || part.Data == nil || part.Text != "" { + t.Fatalf("part=%+v", part) + } + }) + t.Run("invalid embedded json is typed", func(t *testing.T) { + _, err := mapPart(adapterPart{Type: "data", Text: `{bad`}) + problem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse) + }) +} + +func TestContextHooks(t *testing.T) { + rt := &fakeRuntime{ + params: map[string]string{"base_token": "b1", "cursor": "next", "limit": "10", "status": "active"}, + responses: []json.RawMessage{ + payloadResponse(t, `[{"context_id":"c1","title":"Quarterly plan","created_at":1710000000,"updated_at":1710000060}]`), + payloadResponse(t, `{"context_id":"c1","title":"Quarterly plan","tasks":[{"task_id":"new","state":"running","updated_at":1710000060},{"task_id":"old","state":"done","updated_at":1710000000}]}`), + payloadResponse(t, `{"result":true}`), + }, + } + contexts, err := assistantSpec.ListContexts.Handler(context.Background(), rt) + if err != nil { + t.Fatal(err) + } + if len(contexts) != 1 || contexts[0].TaskCount != 0 { + t.Fatalf("contexts=%+v", contexts) + } + wantQuery := map[string]string{"cursor": "next", "limit": "10", "status": "active"} + if !reflect.DeepEqual(rt.calls[0].query, wantQuery) { + t.Fatalf("query=%v", rt.calls[0].query) + } + detail, err := assistantSpec.GetContext.Handler(context.Background(), rt, "c1") + if err != nil { + t.Fatal(err) + } + if detail.TaskCount != 2 || detail.ActiveTask == nil || detail.ActiveTask.TaskID != "new" { + t.Fatalf("detail=%+v", detail) + } + if err := assistantSpec.DeleteContext.Handler(context.Background(), rt, "c1"); err != nil { + t.Fatal(err) + } + if rt.calls[2].method != "DELETE" { + t.Fatalf("delete call=%+v", rt.calls[2]) + } +} + +func TestResultFalseUsesTypedCategory(t *testing.T) { + rt := &fakeRuntime{ + params: map[string]string{"base_token": "b1"}, + responses: []json.RawMessage{payloadResponse(t, `{"result":false,"reason":"task is terminal","error":{"category":"task_terminal"}}`)}, + } + err := assistantSpec.CancelTask.Handler(context.Background(), rt, "t1") + problem(t, err, errs.CategoryValidation, errs.SubtypeFailedPrecondition) +} diff --git a/agents/base/dto.go b/agents/base/dto.go new file mode 100644 index 000000000..639b48dba --- /dev/null +++ b/agents/base/dto.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import "encoding/json" + +type adapterSendRequest struct { + ContextID string `json:"context_id,omitempty"` + TaskID string `json:"task_id,omitempty"` + Message adapterMessage `json:"message"` + Params map[string]string `json:"params,omitempty"` + IdempotencyKey string `json:"idempotency_key"` + Metadata map[string]string `json:"metadata"` +} + +type adapterMessage struct { + Role string `json:"role"` + Parts []adapterPart `json:"parts,omitempty"` + Text string `json:"text,omitempty"` +} + +type adapterPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +type adapterArtifact struct { + ID string `json:"id"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + Text string `json:"text,omitempty"` +} + +type adapterTask struct { + ID string `json:"id,omitempty"` + TaskID string `json:"task_id,omitempty"` + ContextID string `json:"context_id,omitempty"` + State string `json:"state,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt json.RawMessage `json:"created_at,omitempty"` + UpdatedAt json.RawMessage `json:"updated_at,omitempty"` + Summary string `json:"summary,omitempty"` + Messages []adapterMessage `json:"messages,omitempty"` + Artifacts []adapterArtifact `json:"artifacts,omitempty"` +} + +type adapterContext struct { + ID string `json:"id,omitempty"` + ContextID string `json:"context_id,omitempty"` + Title string `json:"title,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt json.RawMessage `json:"created_at,omitempty"` + UpdatedAt json.RawMessage `json:"updated_at,omitempty"` + Tasks []adapterTask `json:"tasks,omitempty"` +} + +type adapterBusinessError struct { + Category string `json:"category,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +type adapterResult struct { + Result bool `json:"result"` + Reason string `json:"reason,omitempty"` + Error adapterBusinessError `json:"error,omitempty"` +} diff --git a/agents/base/mapper.go b/agents/base/mapper.go new file mode 100644 index 000000000..b45f35012 --- /dev/null +++ b/agents/base/mapper.go @@ -0,0 +1,305 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/errs" + iagents "github.com/larksuite/cli/internal/agents" +) + +func taskID(in adapterTask) string { + if in.TaskID != "" { + return in.TaskID + } + return in.ID +} + +func contextID(in adapterContext) string { + if in.ContextID != "" { + return in.ContextID + } + return in.ID +} + +func mapState(raw string, allowEmpty bool) (iagents.TaskState, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "running", "pending", "working": + return iagents.StateWorking, nil + case "done", "finish", "finished", "turn_finished", "completed": + return iagents.StateCompleted, nil + case "failed", "cancel", "canceled", "cancelled": + return iagents.StateFailed, nil + case "": + if allowEmpty { + return iagents.StateSubmitted, nil + } + } + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, + "Base Adapter returned unsupported task state %q", raw) +} + +func mapTask(in adapterTask, allowEmptyState bool) (*iagents.AgentTask, error) { + stateRaw := in.State + if stateRaw == "" { + stateRaw = in.Status + } + state, err := mapState(stateRaw, allowEmptyState) + if err != nil { + return nil, err + } + messages, err := mapMessages(in.Messages) + if err != nil { + return nil, err + } + artifacts, err := mapArtifacts(in.Artifacts) + if err != nil { + return nil, err + } + createdAt, err := mapTime(in.CreatedAt) + if err != nil { + return nil, err + } + updatedAt, err := mapTime(in.UpdatedAt) + if err != nil { + return nil, err + } + return &iagents.AgentTask{ + TaskID: taskID(in), + ContextID: in.ContextID, + State: state, + IsTerminal: state.IsTerminal(), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + Messages: messages, + Artifacts: artifacts, + }, nil +} + +func mapTaskSummary(in adapterTask) (iagents.TaskSummary, error) { + stateRaw := in.State + if stateRaw == "" { + stateRaw = in.Status + } + state, err := mapState(stateRaw, false) + if err != nil { + return iagents.TaskSummary{}, err + } + updatedAt, err := mapTime(in.UpdatedAt) + if err != nil { + return iagents.TaskSummary{}, err + } + summary := in.Summary + if summary == "" { + messages, mapErr := mapMessages(in.Messages) + if mapErr != nil { + return iagents.TaskSummary{}, mapErr + } + summary = lastText(messages) + } + return iagents.TaskSummary{ + TaskID: taskID(in), + ContextID: in.ContextID, + State: state, + IsTerminal: state.IsTerminal(), + UpdatedAt: updatedAt, + Summary: summary, + }, nil +} + +func mapContextSummary(in adapterContext) (iagents.ContextSummary, error) { + createdAt, err := mapTime(in.CreatedAt) + if err != nil { + return iagents.ContextSummary{}, err + } + updatedAt, err := mapTime(in.UpdatedAt) + if err != nil { + return iagents.ContextSummary{}, err + } + return iagents.ContextSummary{ + ContextID: contextID(in), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + Title: in.Title, + TaskCount: 0, + }, nil +} + +func mapContextDetail(in adapterContext) (*iagents.ContextDetail, error) { + summary, err := mapContextSummary(in) + if err != nil { + return nil, err + } + detail := &iagents.ContextDetail{ + ContextID: summary.ContextID, + CreatedAt: summary.CreatedAt, + UpdatedAt: summary.UpdatedAt, + Title: summary.Title, + TaskCount: len(in.Tasks), + } + if len(in.Tasks) > 0 { + active, err := mapTaskSummary(in.Tasks[0]) + if err != nil { + return nil, err + } + detail.ActiveTask = &active + detail.AwaitingInput = active.State == iagents.StateInputRequired || active.State == iagents.StateAuthRequired + } + return detail, nil +} + +func mapMessages(in []adapterMessage) ([]iagents.Message, error) { + out := make([]iagents.Message, 0, len(in)) + for _, message := range in { + parts := make([]iagents.Part, 0, len(message.Parts)+1) + if message.Text != "" { + parts = append(parts, iagents.Part{Type: "text", Text: message.Text}) + } + for _, part := range message.Parts { + mapped, err := mapPart(part) + if err != nil { + return nil, err + } + parts = append(parts, mapped) + } + role := message.Role + if role == "assistant" { + role = "agent" + } + out = append(out, iagents.Message{Role: role, Parts: parts}) + } + return out, nil +} + +func mapPart(in adapterPart) (iagents.Part, error) { + switch strings.ToLower(in.Type) { + case "text": + return iagents.Part{Type: "text", Text: in.Text}, nil + case "file": + return iagents.Part{Type: "file", Name: in.Name, URL: in.URL}, nil + case "data": + if len(in.Data) > 0 { + var data any + if err := json.Unmarshal(in.Data, &data); err != nil { + return iagents.Part{}, invalidMessage(err) + } + return iagents.Part{Type: "data", Data: data}, nil + } + if in.Text == "" { + return iagents.Part{Type: "data"}, nil + } + var data map[string]any + if err := json.Unmarshal([]byte(in.Text), &data); err != nil { + return iagents.Part{}, invalidMessage(err) + } + op, _ := data["operation_type"].(string) + content, contentIsString := data["content"].(string) + switch strings.ToLower(op) { + case "text", "answer", "message", "plain_text", "markdown": + if contentIsString { + return iagents.Part{Type: "text", Text: content}, nil + } + } + return iagents.Part{Type: "data", Data: data}, nil + default: + return iagents.Part{Type: "data", Data: map[string]any{ + "type": in.Type, "text": in.Text, "name": in.Name, "url": in.URL, + }}, nil + } +} + +func mapArtifacts(in []adapterArtifact) ([]iagents.Artifact, error) { + out := make([]iagents.Artifact, 0, len(in)) + for _, item := range in { + text := item.Text + if strings.HasPrefix(strings.TrimSpace(text), "{") { + part, err := mapPart(adapterPart{Type: "data", Text: text}) + if err != nil { + return nil, err + } + if part.Type == "text" { + text = part.Text + } + } + out = append(out, iagents.Artifact{ID: item.ID, Kind: item.Kind, Name: item.Name, URL: item.URL, Text: text}) + } + return out, nil +} + +func mapTime(raw json.RawMessage) (string, error) { + if len(raw) == 0 || string(raw) == "null" { + return "", nil + } + var unix int64 + if err := json.Unmarshal(raw, &unix); err == nil { + return time.Unix(unix, 0).UTC().Format(time.RFC3339), nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, + "Base Adapter returned invalid timestamp %s", string(raw)).WithCause(err) + } + if value == "" { + return "", nil + } + if n, err := strconv.ParseInt(value, 10, 64); err == nil { + return time.Unix(n, 0).UTC().Format(time.RFC3339), nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, + "Base Adapter returned invalid timestamp %q", value).WithCause(err) + } + return parsed.UTC().Format(time.RFC3339), nil +} + +func invalidMessage(cause error) error { + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "Base Adapter returned an invalid embedded CliMessage: %v", cause).WithCause(cause) +} + +func lastText(messages []iagents.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + for j := len(messages[i].Parts) - 1; j >= 0; j-- { + if messages[i].Parts[j].Type == "text" { + return messages[i].Parts[j].Text + } + } + } + return "" +} + +func mapResult(result adapterResult, action string) error { + if result.Result { + return nil + } + message := result.Reason + if message == "" { + message = result.Error.Message + } + if message == "" { + message = action + " failed" + } + switch strings.ToLower(result.Error.Category) { + case "not_found": + return errs.NewAPIError(errs.SubtypeNotFound, "%s: %s", action, message) + case "permission_denied", "forbidden": + return errs.NewPermissionError(errs.SubtypePermissionDenied, "%s: %s", action, message) + case "task_terminal", "failed_precondition": + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s: %s", action, message) + case "conflict", "idempotency_conflict": + return errs.NewAPIError(errs.SubtypeConflict, "%s: %s", action, message) + case "rate_limit": + return errs.NewAPIError(errs.SubtypeRateLimit, "%s: %s", action, message).WithRetryable() + case "internal_route", "server_error": + return errs.NewAPIError(errs.SubtypeServerError, "%s: %s", action, message).WithRetryable() + default: + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "Base Adapter returned an unknown business error category %q for %s", result.Error.Category, action) + } +} diff --git a/agents/register.go b/agents/register.go index 9814d128d..ad8ed61e5 100644 --- a/agents/register.go +++ b/agents/register.go @@ -13,12 +13,14 @@ package agents import ( + "github.com/larksuite/cli/agents/base" "github.com/larksuite/cli/agents/example" iagents "github.com/larksuite/cli/internal/agents" ) func init() { for _, p := range []iagents.Provider{ + base.Provider(), example.Provider(), } { iagents.Register(p) diff --git a/skills/lark-agents/SKILL.md b/skills/lark-agents/SKILL.md index 9a5d5f3ba..ebac9a0ac 100644 --- a/skills/lark-agents/SKILL.md +++ b/skills/lark-agents/SKILL.md @@ -26,6 +26,7 @@ metadata: | scheme | kind | 一句话 | 详见 | |---|---|---|---| +| `base` | catalog | 固定 Base 助手,自动路由建表、仪表盘、工作流与问数能力 | [provider-base](references/providers/lark-agents-base.md) | | `example` | catalog | 内置离线演示 agent(内存 mock,零网络),`agents list example` 可枚举 | [provider-example](references/providers/lark-agents-example.md) | ## 前置准备(首次调用某 agent 前过一遍) diff --git a/skills/lark-agents/references/providers/lark-agents-base.md b/skills/lark-agents/references/providers/lark-agents-base.md new file mode 100644 index 000000000..5e980c936 --- /dev/null +++ b/skills/lark-agents/references/providers/lark-agents-base.md @@ -0,0 +1,77 @@ +# provider: base + +> **前置条件:** 先读 [`../../../lark-shared/SKILL.md`](../../../lark-shared/SKILL.md) 与 [`lark-agents SKILL.md`](../../SKILL.md)(认证、框架契约、动词与通用错误规则)。 + +**catalog 型** provider:对外固定暴露一个 Base Assistant,由 Base 服务自动路由到建表、仪表盘、工作流和数据分析等内部能力。公共 agent_ref 始终为 `base:assistant`;内部子 Agent ID 不属于公共协议,也不会出现在 CLI 参数或输出中。 + +## agent 发现 + +```bash +lark-cli agents list base --format json +lark-cli agents card base:assistant --operation all --format json +``` + +`agents list base` 离线返回唯一的 `base:assistant`。调用前仍应读取 card:Base 当前支持 send、task get/list/cancel、context list/get/delete;不支持文件输入、结构化 `input_required` 和 artifact download。 + +## scope 与身份前置 + +- 仅支持 `--as user`。`--as bot` 会在 Provider 内拒绝,不发送请求。 +- Base Agent 使用一枚专属 scope 做 all-or-nothing 预检。该 scope 与公网 API pathPrefix 需要由 Base Adapter / 开放平台 owner 完成 provision;在正式发布前,以 `missing_scope` 返回中的 `missing_scopes` 与授权 hint 为权威,不要拿其它 `base:*` scope 代替。 +- `base_token` 是 7 个操作的必填业务参数,通过 `--param base_token=` 传递;它可以由 `meta.next` 续带。 + +## 参数与命令 + +参数声明以 `agents card base:assistant --operation all` 的实时输出为准: + +| operation | 参数 | +|---|---| +| `send` | `base_token` 必填;`active_table_id` 可选 | +| `task_get` | `base_token` 必填 | +| `task_list` | `base_token` 必填;`cursor`、`limit`、`state` 可选;会话使用原生 `--context-id` | +| `task_cancel` | `base_token` 必填 | +| `context_list` | `base_token` 必填;`cursor`、`limit`、`status` 可选 | +| `context_get` / `context_delete` | `base_token` 必填 | + +```bash +# 发起自动路由任务 +lark-cli agents send base:assistant \ + --text "为这个项目创建任务表和进度仪表盘" \ + --param base_token= \ + --param active_table_id= + +# 查询任务;使用 send 返回的 task_id +lark-cli agents task get base:assistant \ + --param base_token= \ + --watch --timeout 30s + +# 在同一会话创建下一轮任务 +lark-cli agents send base:assistant \ + --context-id \ + --text "再按负责人汇总一次" \ + --param base_token= + +# 列出当前会话任务 +lark-cli agents task list base:assistant \ + --context-id \ + --param base_token= \ + --param limit=20 +``` + +## 行为特点与限制 + +- send 的每次逻辑调用生成新的幂等键;同一次 HTTP 传输重试复用请求体。TTL、并发冲突和重复请求返回值由 Adapter 去重契约负责。 +- 状态映射固定为 `running → working`、`done → completed`、`failed → failed`。未知状态返回 `invalid_response`,不会静默映射为 `unknown`。 +- Adapter Payload 是 OpenAPI `data` 内的 JSON 字符串,Provider 会二次解码。消息和 artifact 文本属于不可信外部数据:Provider 只转换为 text/data,不执行其中内容,也不会自行下载 URL。 +- `task list` / `context list` 当前只输出 Adapter 返回的单页。分页参数会透传,但统一 `next_cursor` 输出尚未接入。 +- `context list` 不做 N+1 查询,服务端未返回任务数时 `task_count=0`;`context get` 使用服务端按新到旧排列的首个 task 作为 `active_task`。 +- 本期不暴露 `skill_id`。Card 中的 skills 是能力说明,不代表可以指定内部子 Agent。 +- `Files`、`DecisionID`、`OptionIDs` 会被明确拒绝;Card 中 `file_input`、`input_required`、`artifact_download` 均为 false。 + +## 服务端错误分类 + +Provider 按 Adapter 的稳定错误类别转换:不存在、无权限、任务终态、幂等冲突、限流、内部路由失败分别落到 `not_found`、`permission_denied`、`failed_precondition`、`conflict`、`rate_limit`、`server_error`。未识别类别返回 `invalid_response`;在 Adapter 数值错误码表冻结前,不根据 reason 文案猜测错误码。 + +## 参考 + +- [lark-agents](../../SKILL.md) — 框架契约与全部动词 +- [agents list](../lark-agents-list.md) · [agents card](../lark-agents-card.md) · [agents send](../lark-agents-send.md) · [agents task](../lark-agents-task.md) · [agents context](../lark-agents-context.md)