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.
This commit is contained in:
Xiangyang Hu
2026-07-13 22:26:02 +08:00
committed by huxiangyang.cn
parent 63f78c18f7
commit 9efd859fb5
8 changed files with 1086 additions and 0 deletions

View File

@@ -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
}
}

129
agents/base/base.go Normal file
View File

@@ -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)
}

307
agents/base/base_test.go Normal file
View File

@@ -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)
}

72
agents/base/dto.go Normal file
View File

@@ -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"`
}

305
agents/base/mapper.go Normal file
View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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 前过一遍)

View File

@@ -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`。调用前仍应读取 cardBase 当前支持 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=<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=<base-token> \
--param active_table_id=<table-id>
# 查询任务;使用 send 返回的 task_id
lark-cli agents task get base:assistant <task-id> \
--param base_token=<base-token> \
--watch --timeout 30s
# 在同一会话创建下一轮任务
lark-cli agents send base:assistant \
--context-id <context-id> \
--text "再按负责人汇总一次" \
--param base_token=<base-token>
# 列出当前会话任务
lark-cli agents task list base:assistant \
--context-id <context-id> \
--param base_token=<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)