Merge pull request #1534 from trheyi/main

feat(assistant): add hot-reload functionality for assistants and enhance gRPC metadata handling
This commit is contained in:
Max 2026-05-13 21:28:16 +08:00 committed by GitHub
commit cb95bb7871
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1334 additions and 18 deletions

View file

@ -3,6 +3,7 @@ package assistant
import ( import (
"fmt" "fmt"
"path" "path"
"strings"
"github.com/yaoapp/gou/fs" "github.com/yaoapp/gou/fs"
"github.com/yaoapp/yao/agent/caller" "github.com/yaoapp/yao/agent/caller"
@ -27,6 +28,28 @@ func init() {
return &agentCallerWrapper{ast: ast}, nil return &agentCallerWrapper{ast: ast}, nil
} }
// Initialize AssistantReloadFunc for hot-reload after deploy
caller.AssistantReloadFunc = func(id string) error {
p := "/assistants/" + strings.Replace(id, ".", "/", 1)
ast, err := LoadPath(p)
if err != nil {
return err
}
ast.BuiltIn = true
ast.Readonly = true
if ast.Tags == nil {
ast.Tags = []string{}
}
if err := ast.Save(); err != nil {
return err
}
if err := ast.initialize(); err != nil {
return err
}
loaded.Put(ast)
return nil
}
// Initialize Agent JSAPI factory for ctx.agent.* methods // Initialize Agent JSAPI factory for ctx.agent.* methods
caller.SetJSAPIFactory() caller.SetJSAPIFactory()

View file

@ -235,6 +235,7 @@ func (ast *Assistant) executeSandboxV2Stream(
Token: tok, Token: tok,
Logger: ctx.Logger, Logger: ctx.Logger,
UserExplicit: p.Options != nil && p.Options.Connector != "", UserExplicit: p.Options != nil && p.Options.Connector != "",
Locale: ctx.Locale,
} }
execReq := &sandboxv2.ExecuteRequest{ execReq := &sandboxv2.ExecuteRequest{

View file

@ -15,3 +15,7 @@ type AgentCaller interface {
// AgentGetterFunc is a function type that gets an agent by ID // AgentGetterFunc is a function type that gets an agent by ID
// This should be set by the assistant package during initialization // This should be set by the assistant package during initialization
var AgentGetterFunc func(agentID string) (AgentCaller, error) var AgentGetterFunc func(agentID string) (AgentCaller, error)
// AssistantReloadFunc reloads a single assistant from disk after deploy.
// Set by the assistant package during initialization.
var AssistantReloadFunc func(id string) error

View file

@ -140,6 +140,10 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
} }
env["WORKDIR"] = workDir env["WORKDIR"] = workDir
if req.Locale != "" {
env["CTX_LOCALE"] = req.Locale
}
assistantID := req.AssistantID assistantID := req.AssistantID
if assistantID != "" { if assistantID != "" {
configDir := p.PathJoin(workDir, ".yao", "assistants", assistantID) configDir := p.PathJoin(workDir, ".yao", "assistants", assistantID)

View file

@ -329,8 +329,9 @@ func resolveOwnerID(ctx *agentContext.Context) string {
} }
// pickNodeByFilter selects a random online node that satisfies the given filter // pickNodeByFilter selects a random online node that satisfies the given filter
// and image requirement. If image is non-empty, candidate nodes must have a // and image requirement. If image is non-empty, nodes with a container runtime
// container runtime (Docker or K8s). // (Docker or K8s) are preferred; if none are available, host_exec nodes are
// accepted as fallback (ResolveNodeID will resolve them to host mode).
func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error) { func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error) {
reg := registry.Global() reg := registry.Global()
if reg == nil { if reg == nil {
@ -339,6 +340,7 @@ func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error
nodes := reg.List() nodes := reg.List()
var candidates []string var candidates []string
var hostExecFallback []string
for _, n := range nodes { for _, n := range nodes {
if n.Status != "online" && n.Status != "" { if n.Status != "online" && n.Status != "" {
continue continue
@ -372,12 +374,20 @@ func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error
} }
if image != "" && !(n.Capabilities.Docker || n.Capabilities.K8s) { if image != "" && !(n.Capabilities.Docker || n.Capabilities.K8s) {
if n.Capabilities.HostExec {
hostExecFallback = append(hostExecFallback, n.TaiID)
}
continue continue
} }
candidates = append(candidates, n.TaiID) candidates = append(candidates, n.TaiID)
} }
if len(candidates) == 0 && len(hostExecFallback) > 0 {
log.Trace("[sandbox/v2] pickNodeByFilter: no container node for image %q, falling back to host_exec node", image)
candidates = hostExecFallback
}
if len(candidates) == 0 { if len(candidates) == 0 {
kind := "" kind := ""
os := "" os := ""

View file

@ -60,4 +60,5 @@ type StreamRequest struct {
Token *SandboxToken // current user's sandbox token for MCP callbacks Token *SandboxToken // current user's sandbox token for MCP callbacks
Logger *agentContext.RequestLogger // request-scoped logger propagated from agent context Logger *agentContext.RequestLogger // request-scoped logger propagated from agent context
UserExplicit bool // true when the user explicitly selected the primary connector UserExplicit bool // true when the user explicitly selected the primary connector
Locale string // user locale (e.g. "zh-cn", "en-us") for i18n in MCP tools
} }

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
goumcp "github.com/yaoapp/gou/mcp" goumcp "github.com/yaoapp/gou/mcp"
@ -28,7 +29,7 @@ func authProviderFromCtx(ctx context.Context) *grpcAuthProvider {
if info == nil { if info == nil {
return nil return nil
} }
return &grpcAuthProvider{m: map[string]interface{}{ m := map[string]interface{}{
"sub": info.Subject, "sub": info.Subject,
"client_id": info.ClientID, "client_id": info.ClientID,
"scope": info.Scope, "scope": info.Scope,
@ -36,7 +37,19 @@ func authProviderFromCtx(ctx context.Context) *grpcAuthProvider {
"user_id": info.UserID, "user_id": info.UserID,
"team_id": info.TeamID, "team_id": info.TeamID,
"tenant_id": info.TenantID, "tenant_id": info.TenantID,
}} }
if md, ok := metadata.FromIncomingContext(ctx); ok {
if ids := md.Get("x-workspace-id"); len(ids) > 0 && ids[0] != "" {
m["workspace_id"] = ids[0]
}
if ids := md.Get("x-sandbox-id"); len(ids) > 0 && ids[0] != "" {
m["sandbox_id"] = ids[0]
}
if vals := md.Get("x-locale"); len(vals) > 0 && vals[0] != "" {
m["locale"] = vals[0]
}
}
return &grpcAuthProvider{m: m}
} }
// MCPListTools lists all available MCP tools for a given session. // MCPListTools lists all available MCP tools for a given session.

129
tools/agent/agent.go Normal file
View file

@ -0,0 +1,129 @@
package agent
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"strings"
"github.com/yaoapp/gou/process"
taiworkspace "github.com/yaoapp/yao/tai/workspace"
ws "github.com/yaoapp/yao/workspace"
"google.golang.org/grpc/metadata"
)
//go:embed list_schema.json
var ListSchemaJSON []byte
//go:embed download_schema.json
var DownloadSchemaJSON []byte
//go:embed deploy_schema.json
var DeploySchemaJSON []byte
//go:embed reference_schema.json
var ReferenceSchemaJSON []byte
//go:embed connectors_schema.json
var ConnectorsSchemaJSON []byte
const allowedDeployNamespace = "smith"
type agentInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Capabilities string `json:"capabilities,omitempty"`
}
type packageDSL struct {
Name string `json:"name"`
Description string `json:"description"`
Capabilities string `json:"capabilities"`
}
func resolveWorkspaceFS(proc *process.Process) (taiworkspace.FS, error) {
workspaceID := extractWorkspaceID(proc)
if workspaceID == "" {
return nil, fmt.Errorf("workspace_id not available (container must set CTX_WORKSPACE_ID)")
}
fs, err := ws.M().FS(context.Background(), workspaceID)
if err != nil {
return nil, fmt.Errorf("workspace %s: %w", workspaceID, err)
}
return fs, nil
}
func extractWorkspaceID(proc *process.Process) string {
if proc.Context == nil {
return ""
}
md, ok := metadata.FromIncomingContext(proc.Context)
if !ok {
return ""
}
ids := md.Get("x-workspace-id")
if len(ids) > 0 && ids[0] != "" {
return ids[0]
}
return ""
}
func extractLocale(proc *process.Process) string {
if proc.Context == nil {
return "en-us"
}
md, ok := metadata.FromIncomingContext(proc.Context)
if !ok {
return "en-us"
}
vals := md.Get("x-locale")
if len(vals) > 0 && vals[0] != "" {
return strings.ToLower(vals[0])
}
return "en-us"
}
func validateID(id string) error {
if strings.Contains(id, "..") {
return fmt.Errorf("invalid id: path traversal not allowed")
}
if strings.ContainsAny(id, "/\\") {
return fmt.Errorf("invalid id: use dot notation (e.g. 'yao.slides')")
}
parts := strings.SplitN(id, ".", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return fmt.Errorf("invalid id format: expected 'namespace.name' (e.g. 'yao.slides')")
}
return nil
}
func idToPath(id string) string {
return strings.Replace(id, ".", "/", 1)
}
func settingStr(setting map[string]interface{}, key string) string {
if v, ok := setting[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func sanitizeCapabilities(caps interface{}) interface{} {
data, err := json.Marshal(caps)
if err != nil {
return nil
}
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
return caps
}
delete(m, "key")
delete(m, "secret")
delete(m, "token")
return m
}

553
tools/agent/agent_test.go Normal file
View file

@ -0,0 +1,553 @@
package agent
import (
"context"
"encoding/json"
"os"
"testing"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/llmprovider"
"github.com/yaoapp/yao/setting"
"github.com/yaoapp/yao/test"
"google.golang.org/grpc/metadata"
)
func TestMain(m *testing.M) {
test.Prepare(nil, config.Conf)
defer test.Clean()
os.Exit(m.Run())
}
// --- Pure function tests (no app environment needed) ---
func TestValidateID_Valid(t *testing.T) {
valid := []string{
"yao.slides",
"smith.weather",
"ns.agent-name",
"a.b",
}
for _, id := range valid {
if err := validateID(id); err != nil {
t.Errorf("validateID(%q) unexpected error: %v", id, err)
}
}
}
func TestValidateID_Invalid(t *testing.T) {
cases := []struct {
id string
want string
}{
{"", "invalid id format"},
{"nodot", "invalid id format"},
{".leading", "invalid id format"},
{"trailing.", "invalid id format"},
{"a..b", "path traversal"},
{"a/b", "dot notation"},
{"a\\b", "dot notation"},
{"ns/name.ext", "dot notation"},
}
for _, tc := range cases {
err := validateID(tc.id)
if err == nil {
t.Errorf("validateID(%q) expected error containing %q, got nil", tc.id, tc.want)
continue
}
if !contains(err.Error(), tc.want) {
t.Errorf("validateID(%q) error = %q, want substring %q", tc.id, err.Error(), tc.want)
}
}
}
func TestIdToPath(t *testing.T) {
cases := []struct {
id string
want string
}{
{"yao.slides", "yao/slides"},
{"smith.weather", "smith/weather"},
{"ns.agent.extra", "ns/agent.extra"},
}
for _, tc := range cases {
got := idToPath(tc.id)
if got != tc.want {
t.Errorf("idToPath(%q) = %q, want %q", tc.id, got, tc.want)
}
}
}
func TestSettingStr(t *testing.T) {
m := map[string]interface{}{
"key1": "value1",
"key2": 42,
"key3": nil,
}
if v := settingStr(m, "key1"); v != "value1" {
t.Errorf("settingStr(key1) = %q, want %q", v, "value1")
}
if v := settingStr(m, "key2"); v != "" {
t.Errorf("settingStr(key2) = %q, want empty (non-string)", v)
}
if v := settingStr(m, "key3"); v != "" {
t.Errorf("settingStr(key3) = %q, want empty (nil value)", v)
}
if v := settingStr(m, "missing"); v != "" {
t.Errorf("settingStr(missing) = %q, want empty", v)
}
if v := settingStr(nil, "any"); v != "" {
t.Errorf("settingStr(nil map) = %q, want empty", v)
}
}
func TestSanitizeCapabilities(t *testing.T) {
caps := map[string]interface{}{
"tool_calls": true,
"streaming": true,
"key": "sk-secret-123",
"secret": "my-secret",
"token": "bearer-xyz",
"reasoning": false,
}
result := sanitizeCapabilities(caps)
m, ok := result.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", result)
}
if _, has := m["key"]; has {
t.Error("sanitizeCapabilities should remove 'key'")
}
if _, has := m["secret"]; has {
t.Error("sanitizeCapabilities should remove 'secret'")
}
if _, has := m["token"]; has {
t.Error("sanitizeCapabilities should remove 'token'")
}
if m["tool_calls"] != true {
t.Error("sanitizeCapabilities should preserve 'tool_calls'")
}
if m["streaming"] != true {
t.Error("sanitizeCapabilities should preserve 'streaming'")
}
if m["reasoning"] != false {
t.Error("sanitizeCapabilities should preserve 'reasoning'")
}
}
func TestSanitizeCapabilities_NonMap(t *testing.T) {
result := sanitizeCapabilities("not-a-map")
if result != "not-a-map" {
t.Errorf("non-map input should be returned as-is, got %v", result)
}
}
func TestSanitizeCapabilities_Nil(t *testing.T) {
result := sanitizeCapabilities(nil)
if m, ok := result.(map[string]interface{}); ok && m != nil {
t.Errorf("nil input should yield nil map, got %v", m)
}
}
func TestExtractWorkspaceID_WithMetadata(t *testing.T) {
md := metadata.Pairs("x-workspace-id", "ws-abc-123")
ctx := metadata.NewIncomingContext(context.Background(), md)
proc := &process.Process{Context: ctx}
id := extractWorkspaceID(proc)
if id != "ws-abc-123" {
t.Errorf("extractWorkspaceID = %q, want %q", id, "ws-abc-123")
}
}
func TestExtractWorkspaceID_NoMetadata(t *testing.T) {
proc := &process.Process{Context: context.Background()}
id := extractWorkspaceID(proc)
if id != "" {
t.Errorf("extractWorkspaceID without metadata = %q, want empty", id)
}
}
func TestExtractWorkspaceID_NilContext(t *testing.T) {
proc := &process.Process{}
id := extractWorkspaceID(proc)
if id != "" {
t.Errorf("extractWorkspaceID with nil context = %q, want empty", id)
}
}
func TestExtractWorkspaceID_EmptyValue(t *testing.T) {
md := metadata.Pairs("x-workspace-id", "")
ctx := metadata.NewIncomingContext(context.Background(), md)
proc := &process.Process{Context: ctx}
id := extractWorkspaceID(proc)
if id != "" {
t.Errorf("extractWorkspaceID with empty value = %q, want empty", id)
}
}
func TestExtractWorkspaceID_OtherKeys(t *testing.T) {
md := metadata.Pairs("x-sandbox-id", "sb-123")
ctx := metadata.NewIncomingContext(context.Background(), md)
proc := &process.Process{Context: ctx}
id := extractWorkspaceID(proc)
if id != "" {
t.Errorf("extractWorkspaceID with wrong key = %q, want empty", id)
}
}
func TestSchemaJSON_NonEmpty(t *testing.T) {
schemas := map[string][]byte{
"ListSchemaJSON": ListSchemaJSON,
"DownloadSchemaJSON": DownloadSchemaJSON,
"ReferenceSchemaJSON": ReferenceSchemaJSON,
"DeploySchemaJSON": DeploySchemaJSON,
"ConnectorsSchemaJSON": ConnectorsSchemaJSON,
}
for name, data := range schemas {
if len(data) == 0 {
t.Errorf("%s is empty", name)
continue
}
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Errorf("%s is not valid JSON: %v", name, err)
continue
}
if parsed["name"] == nil {
t.Errorf("%s missing 'name' field", name)
}
if parsed["process"] == nil {
t.Errorf("%s missing 'process' field", name)
}
}
}
// --- Integration tests (require test.Prepare via TestMain) ---
func TestListHandler_All(t *testing.T) {
proc := &process.Process{Args: []interface{}{}}
result := ListHandler(proc)
m, ok := result.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", result)
}
if errMsg, has := m["error"]; has {
t.Fatalf("ListHandler returned error: %v", errMsg)
}
agents, ok := m["agents"]
if !ok {
t.Fatal("ListHandler result missing 'agents' key")
}
agentList, ok := agents.([]agentInfo)
if !ok {
t.Fatalf("agents field is %T, expected []agentInfo", agents)
}
if len(agentList) == 0 {
t.Error("expected at least one agent in yao-dev-app")
}
for _, a := range agentList {
if a.ID == "" {
t.Error("agent ID should not be empty")
}
if !contains(a.ID, ".") {
t.Errorf("agent ID %q should use dot notation", a.ID)
}
}
t.Logf("ListHandler returned %d agents", len(agentList))
}
func TestListHandler_Namespace(t *testing.T) {
proc := &process.Process{Args: []interface{}{"yaobots"}}
result := ListHandler(proc)
m := result.(map[string]interface{})
if errMsg, has := m["error"]; has {
t.Fatalf("ListHandler returned error: %v", errMsg)
}
agentList := m["agents"].([]agentInfo)
for _, a := range agentList {
if !hasPrefix(a.ID, "yaobots.") {
t.Errorf("agent %q should be in yaobots namespace", a.ID)
}
}
t.Logf("namespace 'yaobots': %d agents", len(agentList))
}
func TestListHandler_NonexistentNamespace(t *testing.T) {
proc := &process.Process{Args: []interface{}{"nonexistent_ns_xyz"}}
result := ListHandler(proc)
m := result.(map[string]interface{})
agentList := m["agents"].([]agentInfo)
if len(agentList) != 0 {
t.Errorf("expected 0 agents for nonexistent namespace, got %d", len(agentList))
}
}
func TestListHandler_SkipsYaoInternal(t *testing.T) {
proc := &process.Process{Args: []interface{}{}}
result := ListHandler(proc)
m := result.(map[string]interface{})
agentList := m["agents"].([]agentInfo)
for _, a := range agentList {
if hasPrefix(a.ID, "__yao.") {
t.Errorf("internal agent %q should be filtered out", a.ID)
}
}
}
func TestConnectorsHandler_NoProvider(t *testing.T) {
saved := llmprovider.Global
llmprovider.Global = nil
defer func() { llmprovider.Global = saved }()
proc := &process.Process{Args: []interface{}{}}
result := ConnectorsHandler(proc)
m, ok := result.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", result)
}
errMsg, has := m["error"]
if !has {
t.Fatal("expected error when llmprovider.Global is nil")
}
if !contains(errMsg.(string), "not initialized") {
t.Errorf("error = %q, want substring 'not initialized'", errMsg)
}
}
func TestConnectorsHandler_WithProvider(t *testing.T) {
if err := setting.Init(); err != nil {
t.Skipf("setting.Init failed: %v", err)
}
if err := llmprovider.Init(); err != nil {
t.Skipf("llmprovider.Init failed (may need full env): %v", err)
}
if llmprovider.Global == nil {
t.Skip("llmprovider.Global is nil after Init")
}
proc := &process.Process{Args: []interface{}{}}
result := ConnectorsHandler(proc)
m, ok := result.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", result)
}
if errMsg, has := m["error"]; has {
t.Fatalf("ConnectorsHandler returned error: %v", errMsg)
}
t.Logf("ConnectorsHandler returned %d roles", len(m))
}
func TestDeployHandler_MissingID(t *testing.T) {
proc := &process.Process{Args: []interface{}{""}}
result := DeployHandler(proc)
m := result.(map[string]interface{})
if _, has := m["error"]; !has {
t.Error("expected error for empty id")
}
}
func TestDeployHandler_WrongNamespace(t *testing.T) {
proc := &process.Process{Args: []interface{}{"yao.slides"}}
result := DeployHandler(proc)
m := result.(map[string]interface{})
if m["status"] != "error" {
t.Errorf("expected status 'error' for non-smith namespace, got %v", m["status"])
}
msg, _ := m["message"].(string)
if !contains(msg, "smith") {
t.Errorf("error message should mention 'smith', got %q", msg)
}
}
func TestDeployHandler_InvalidID(t *testing.T) {
cases := []string{"smith/bad", "a..b", "onlyname"}
for _, id := range cases {
proc := &process.Process{Args: []interface{}{id}}
result := DeployHandler(proc)
m := result.(map[string]interface{})
if _, has := m["error"]; !has {
t.Errorf("DeployHandler(%q) expected error", id)
}
}
}
func TestDownloadHandler_MissingID(t *testing.T) {
proc := &process.Process{Args: []interface{}{""}}
result := DownloadHandler(proc)
m := result.(map[string]interface{})
if _, has := m["error"]; !has {
t.Error("expected error for empty id")
}
}
func TestDownloadHandler_InvalidID(t *testing.T) {
cases := []string{"no/slash", "a..b", ""}
for _, id := range cases {
proc := &process.Process{Args: []interface{}{id}}
result := DownloadHandler(proc)
m := result.(map[string]interface{})
if _, has := m["error"]; !has {
t.Errorf("DownloadHandler(%q) expected error", id)
}
}
}
func TestDownloadHandler_WrongNamespace(t *testing.T) {
proc := &process.Process{Args: []interface{}{"yao.slides"}}
result := DownloadHandler(proc)
m := result.(map[string]interface{})
errMsg, has := m["error"]
if !has {
t.Fatal("expected error for non-smith namespace")
}
if !contains(errMsg.(string), "smith") {
t.Errorf("error = %q, want substring 'smith'", errMsg)
}
if !contains(errMsg.(string), "agent_reference") {
t.Errorf("error = %q, should suggest agent_reference", errMsg)
}
}
func TestDownloadHandler_MissingWorkspace(t *testing.T) {
proc := &process.Process{
Args: []interface{}{"smith.test"},
Context: context.Background(),
}
result := DownloadHandler(proc)
m := result.(map[string]interface{})
errMsg, has := m["error"]
if !has {
t.Fatal("expected error when workspace_id is missing")
}
if !contains(errMsg.(string), "workspace_id") {
t.Errorf("error = %q, want substring 'workspace_id'", errMsg)
}
}
func TestReferenceHandler_MissingID(t *testing.T) {
proc := &process.Process{Args: []interface{}{""}}
result := ReferenceHandler(proc)
m := result.(map[string]interface{})
if _, has := m["error"]; !has {
t.Error("expected error for empty id")
}
}
func TestReferenceHandler_InvalidID(t *testing.T) {
cases := []string{"no/slash", "a..b", ""}
for _, id := range cases {
proc := &process.Process{Args: []interface{}{id}}
result := ReferenceHandler(proc)
m := result.(map[string]interface{})
if _, has := m["error"]; !has {
t.Errorf("ReferenceHandler(%q) expected error", id)
}
}
}
func TestReferenceHandler_MissingWorkspace(t *testing.T) {
proc := &process.Process{
Args: []interface{}{"yao.slides"},
Context: context.Background(),
}
result := ReferenceHandler(proc)
m := result.(map[string]interface{})
errMsg, has := m["error"]
if !has {
t.Fatal("expected error when workspace_id is missing")
}
if !contains(errMsg.(string), "workspace_id") {
t.Errorf("error = %q, want substring 'workspace_id'", errMsg)
}
}
func TestDeployHandler_MissingWorkspace(t *testing.T) {
proc := &process.Process{
Args: []interface{}{"smith.test"},
Context: context.Background(),
}
result := DeployHandler(proc)
m := result.(map[string]interface{})
errMsg, has := m["error"]
if !has {
t.Fatal("expected error when workspace_id is missing")
}
if !contains(errMsg.(string), "workspace_id") {
t.Errorf("error = %q, want substring 'workspace_id'", errMsg)
}
}
// --- extractLocale tests ---
func TestExtractLocale_WithMetadata(t *testing.T) {
md := metadata.Pairs("x-locale", "zh-cn")
ctx := metadata.NewIncomingContext(context.Background(), md)
proc := &process.Process{Context: ctx}
locale := extractLocale(proc)
if locale != "zh-cn" {
t.Errorf("extractLocale = %q, want %q", locale, "zh-cn")
}
}
func TestExtractLocale_UpperCase(t *testing.T) {
md := metadata.Pairs("x-locale", "ZH-CN")
ctx := metadata.NewIncomingContext(context.Background(), md)
proc := &process.Process{Context: ctx}
locale := extractLocale(proc)
if locale != "zh-cn" {
t.Errorf("extractLocale = %q, want %q (should lowercase)", locale, "zh-cn")
}
}
func TestExtractLocale_NoMetadata(t *testing.T) {
proc := &process.Process{Context: context.Background()}
locale := extractLocale(proc)
if locale != "en-us" {
t.Errorf("extractLocale without metadata = %q, want default %q", locale, "en-us")
}
}
func TestExtractLocale_NilContext(t *testing.T) {
proc := &process.Process{}
locale := extractLocale(proc)
if locale != "en-us" {
t.Errorf("extractLocale with nil context = %q, want default %q", locale, "en-us")
}
}
func TestExtractLocale_EmptyValue(t *testing.T) {
md := metadata.Pairs("x-locale", "")
ctx := metadata.NewIncomingContext(context.Background(), md)
proc := &process.Process{Context: ctx}
locale := extractLocale(proc)
if locale != "en-us" {
t.Errorf("extractLocale with empty value = %q, want default %q", locale, "en-us")
}
}
// --- helpers ---
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchSubstring(s, substr)
}
func searchSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func hasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}

63
tools/agent/connectors.go Normal file
View file

@ -0,0 +1,63 @@
package agent
import (
"fmt"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/llmprovider"
"github.com/yaoapp/yao/openapi/oauth/authorized"
)
// ConnectorsHandler handles the agent_connectors tool.
// No input args. Returns the current user's LLM connector matrix without keys.
func ConnectorsHandler(proc *process.Process) interface{} {
authInfo := authorized.ProcessAuthInfo(proc)
if llmprovider.Global == nil {
return map[string]interface{}{"error": "llmprovider not initialized"}
}
var roles map[string]llmprovider.RoleTarget
var err error
if authInfo != nil && authInfo.UserID != "" {
roles, err = llmprovider.Global.ListRolesByUser(authInfo.UserID)
} else {
roles, err = llmprovider.Global.ListRoles()
}
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("failed to list roles: %s", err.Error())}
}
result := make(map[string]interface{}, len(roles))
for role, target := range roles {
connID := target.Provider
info := map[string]interface{}{
"id": connID,
"model": target.Model,
}
conn, exists := connector.Connectors[connID]
if exists {
setting := conn.Setting()
meta := conn.GetMetaInfo()
if meta.Label != "" {
info["name"] = meta.Label
}
if model, ok := setting["model"]; ok && info["model"] == "" {
info["model"] = model
}
if caps, ok := setting["capabilities"]; ok {
info["capabilities"] = sanitizeCapabilities(caps)
}
if t := settingStr(setting, "auth_mode"); t != "" {
info["type"] = "openai"
}
}
result[role] = info
}
return result
}

View file

@ -0,0 +1,10 @@
{
"name": "agent_connectors",
"description": "Get the current user's LLM connector matrix. Returns connector metadata for each role (default, heavy, light, vision, etc.) without API keys. Use this to understand available models and their capabilities.",
"process": "tools.agent_connectors",
"inputSchema": {
"type": "object",
"properties": {}
},
"x-process-args": []
}

73
tools/agent/deploy.go Normal file
View file

@ -0,0 +1,73 @@
package agent
import (
"fmt"
"path/filepath"
"strings"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/caller"
"github.com/yaoapp/yao/config"
)
// DeployHandler handles the agent_deploy tool.
// Args[0]: id (string, dot notation e.g. "smith.weather")
// Args[1]: message (string, optional deploy message)
func DeployHandler(proc *process.Process) interface{} {
id := proc.ArgsString(0)
if id == "" {
return map[string]interface{}{"error": "id is required (e.g. 'smith.weather')"}
}
if err := validateID(id); err != nil {
return map[string]interface{}{"error": err.Error()}
}
parts := strings.SplitN(id, ".", 2)
if len(parts) != 2 || parts[0] != allowedDeployNamespace {
return map[string]interface{}{
"status": "error",
"message": fmt.Sprintf("deploy restricted to namespace '%s'", allowedDeployNamespace),
}
}
wsFS, err := resolveWorkspaceFS(proc)
if err != nil {
return map[string]interface{}{"error": err.Error()}
}
relPath := idToPath(id)
appRoot := config.Conf.Root
srcPath := filepath.Join("agent-smith-dev", "assistants", relPath)
dstURI := "local:///" + filepath.Join(appRoot, "assistants", relPath)
result, copyErr := wsFS.Copy(srcPath, dstURI)
if copyErr != nil {
return map[string]interface{}{"error": fmt.Sprintf("deploy failed: %s", copyErr.Error())}
}
files := 0
if result != nil {
files = result.FilesSynced
}
msg := ""
if len(proc.Args) > 1 {
msg = proc.ArgsString(1)
}
if msg != "" {
log.Info("[agent_deploy] %s: %s (%d files)", id, msg, files)
}
if caller.AssistantReloadFunc != nil {
if err := caller.AssistantReloadFunc(id); err != nil {
log.Warn("[agent_deploy] reload %s: %s (files deployed, restart to apply)", id, err.Error())
}
}
return map[string]interface{}{
"status": "ok",
"path": filepath.Join("assistants", relPath),
"synced_files": files,
}
}

View file

@ -0,0 +1,20 @@
{
"name": "agent_deploy",
"description": "Deploy agent source code from the sandbox development directory to the host. Restricted to the 'smith' namespace only.",
"process": "tools.agent_deploy",
"inputSchema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Agent ID in dot notation (e.g. 'smith.weather'). Must use 'smith' namespace."
},
"message": {
"type": "string",
"description": "Optional deploy message for logging purposes."
}
},
"required": ["id"]
},
"x-process-args": ["$args.id", "$args.message"]
}

57
tools/agent/download.go Normal file
View file

@ -0,0 +1,57 @@
package agent
import (
"fmt"
"path/filepath"
"strings"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/config"
)
// DownloadHandler handles the agent_download tool.
// Restricted to the smith namespace — used for downloading agents to edit.
// For read-only reference of other namespaces, use agent_reference instead.
// Args[0]: id (string, dot notation e.g. "smith.weather")
func DownloadHandler(proc *process.Process) interface{} {
id := proc.ArgsString(0)
if id == "" {
return map[string]interface{}{"error": "id is required (e.g. 'smith.weather')"}
}
if err := validateID(id); err != nil {
return map[string]interface{}{"error": err.Error()}
}
parts := strings.SplitN(id, ".", 2)
if len(parts) != 2 || parts[0] != allowedDeployNamespace {
return map[string]interface{}{
"error": fmt.Sprintf("download restricted to '%s' namespace; use agent_reference for other agents", allowedDeployNamespace),
}
}
wsFS, err := resolveWorkspaceFS(proc)
if err != nil {
return map[string]interface{}{"error": err.Error()}
}
relPath := idToPath(id)
appRoot := config.Conf.Root
srcURI := "local:///" + filepath.Join(appRoot, "assistants", relPath)
dstPath := filepath.Join("agent-smith-dev", "assistants", relPath)
result, copyErr := wsFS.Copy(srcURI, dstPath)
if copyErr != nil {
return map[string]interface{}{"error": fmt.Sprintf("download failed: %s", copyErr.Error())}
}
files := 0
if result != nil {
files = result.FilesSynced
}
return map[string]interface{}{
"status": "ok",
"path": dstPath,
"files": files,
}
}

View file

@ -0,0 +1,16 @@
{
"name": "agent_download",
"description": "Download a smith-namespace agent into the development directory for editing. Restricted to the 'smith' namespace only. For read-only reference of other agents, use agent_reference instead.",
"process": "tools.agent_download",
"inputSchema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Agent ID in dot notation, must be smith namespace (e.g. 'smith.weather')"
}
},
"required": ["id"]
},
"x-process-args": ["$args.id"]
}

146
tools/agent/list.go Normal file
View file

@ -0,0 +1,146 @@
package agent
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
goufs "github.com/yaoapp/gou/fs"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/agent/i18n"
)
// ListHandler handles the agent_list tool.
// Args[0]: namespace (string, optional)
func ListHandler(proc *process.Process) interface{} {
namespace := ""
if len(proc.Args) > 0 {
namespace = proc.ArgsString(0)
}
locale := extractLocale(proc)
app, err := goufs.Get("app")
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("app filesystem: %s", err.Error())}
}
root := "/assistants"
exists, _ := app.Exists(root)
if !exists {
return map[string]interface{}{"agents": []agentInfo{}}
}
nsDirs, err := app.ReadDir(root, false)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("read assistants dir: %s", err.Error())}
}
agents := make([]agentInfo, 0)
for _, nsDir := range nsDirs {
nsName := filepath.Base(nsDir)
if namespace != "" && nsName != namespace {
continue
}
agentDirs, err := app.ReadDir(nsDir, false)
if err != nil {
continue
}
for _, agentDir := range agentDirs {
pkgFile := filepath.Join(agentDir, "package.yao")
pkgExists, _ := app.Exists(pkgFile)
if !pkgExists {
continue
}
data, err := app.ReadFile(pkgFile)
if err != nil {
continue
}
var pkg packageDSL
if err := json.Unmarshal(data, &pkg); err != nil {
continue
}
agentName := filepath.Base(agentDir)
id := nsName + "." + agentName
if strings.HasPrefix(id, "__yao.") {
continue
}
name := pkg.Name
description := pkg.Description
capabilities := pkg.Capabilities
resolveLocaleFields(agentDir, locale, &name, &description, &capabilities)
agents = append(agents, agentInfo{
ID: id,
Name: name,
Description: description,
Capabilities: capabilities,
})
}
}
return map[string]interface{}{"agents": agents}
}
// resolveLocaleFields replaces {{ key }} templates in name/description using
// the agent's locales/ directory. Falls back gracefully: exact locale →
// language code (e.g. "zh") → en-us → raw template.
func resolveLocaleFields(agentDir, locale string, fields ...*string) {
hasTemplate := false
for _, f := range fields {
if strings.Contains(*f, "{{") {
hasTemplate = true
break
}
}
if !hasTemplate {
return
}
locales, err := i18n.GetLocales(agentDir)
if err != nil || len(locales) == 0 {
return
}
locales = locales.Flatten()
li := findLocale(locales, locale)
if li == nil {
return
}
for _, f := range fields {
if parsed := li.Parse(*f); parsed != nil {
if s, ok := parsed.(string); ok {
*f = s
}
}
}
}
func findLocale(locales i18n.Map, locale string) *i18n.I18n {
locale = strings.ToLower(locale)
if li, ok := locales[locale]; ok {
return &li
}
parts := strings.SplitN(locale, "-", 2)
if len(parts) > 1 {
if li, ok := locales[parts[0]]; ok {
return &li
}
}
if li, ok := locales["en-us"]; ok {
return &li
}
if li, ok := locales["en"]; ok {
return &li
}
return nil
}

View file

@ -0,0 +1,15 @@
{
"name": "agent_list",
"description": "List available agents on the host. Returns agent ID, name, description, and capabilities. Optionally filter by namespace.",
"process": "tools.agent_list",
"inputSchema": {
"type": "object",
"properties": {
"namespace": {
"type": "string",
"description": "Optional namespace filter (e.g. 'yao', 'smith'). If omitted, lists all agents."
}
}
},
"x-process-args": ["$args.namespace"]
}

48
tools/agent/reference.go Normal file
View file

@ -0,0 +1,48 @@
package agent
import (
"fmt"
"path/filepath"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/config"
)
// ReferenceHandler handles the agent_reference tool.
// Downloads agent source code to .references/ for read-only study.
// Args[0]: id (string, dot notation e.g. "yao.slides")
func ReferenceHandler(proc *process.Process) interface{} {
id := proc.ArgsString(0)
if id == "" {
return map[string]interface{}{"error": "id is required (e.g. 'yao.slides')"}
}
if err := validateID(id); err != nil {
return map[string]interface{}{"error": err.Error()}
}
wsFS, err := resolveWorkspaceFS(proc)
if err != nil {
return map[string]interface{}{"error": err.Error()}
}
relPath := idToPath(id)
appRoot := config.Conf.Root
srcURI := "local:///" + filepath.Join(appRoot, "assistants", relPath)
dstPath := filepath.Join("agent-smith-dev", ".references", relPath)
result, copyErr := wsFS.Copy(srcURI, dstPath)
if copyErr != nil {
return map[string]interface{}{"error": fmt.Sprintf("reference download failed: %s", copyErr.Error())}
}
files := 0
if result != nil {
files = result.FilesSynced
}
return map[string]interface{}{
"status": "ok",
"path": dstPath,
"files": files,
}
}

View file

@ -0,0 +1,16 @@
{
"name": "agent_reference",
"description": "Download agent source code from the host into the .references/ directory for read-only study. Any agent across all namespaces can be downloaded. Use this to study existing agent patterns before building your own.",
"process": "tools.agent_reference",
"inputSchema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Agent ID in dot notation (e.g. 'yao.slides', 'yao.keeper')"
}
},
"required": ["id"]
},
"x-process-args": ["$args.id"]
}

12
tools/mcps/agent.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "yao-agent",
"transport": "process",
"description": "Agent management tools for listing, downloading, deploying agents, and querying connector matrix",
"tools": {
"agent_list": "tools.agent_list",
"agent_download": "tools.agent_download",
"agent_reference": "tools.agent_reference",
"agent_deploy": "tools.agent_deploy",
"agent_connectors": "tools.agent_connectors"
}
}

View file

@ -79,5 +79,10 @@ You have access to Yao system tools via the `tai` command in bash.
| `image_read` | yao-image | Read and analyze images using a vision model | | `image_read` | yao-image | Read and analyze images using a vision model |
| `image_generate` | yao-image | Generate images from text prompts | | `image_generate` | yao-image | Generate images from text prompts |
| `image_providers` | yao-image | List available image generation or vision providers | | `image_providers` | yao-image | List available image generation or vision providers |
| `agent_list` | yao-agent | List available agents on the host |
| `agent_download` | yao-agent | Download smith agent for editing (smith only) |
| `agent_reference` | yao-agent | Download agent source to .references/ for study |
| `agent_deploy` | yao-agent | Deploy agent code to host (smith namespace only) |
| `agent_connectors` | yao-agent | Get LLM connector matrix (no keys) |
The system skills (`yao-web`, `yao-process`, `yao-doc`, `yao-image`) in `$HOME/.claude/skills/` are **auto-discovered** — they contain detailed parameter docs and workflow guidance. You do not need to manually read them; they are loaded automatically when your task matches their description. The system skills (`yao-web`, `yao-process`, `yao-doc`, `yao-image`, `yao-agent`) in `$HOME/.claude/skills/` are **auto-discovered** — they contain detailed parameter docs and workflow guidance. You do not need to manually read them; they are loaded automatically when your task matches their description.

View file

@ -0,0 +1,83 @@
---
name: yao-agent
description: Agent management expert. ALWAYS invoke this skill when you need to list available agents, download or reference agent source code, deploy agent code to the host, or query the LLM connector matrix. Do not guess agent structures — use this skill first.
---
# Agent Tools
Five tools for managing agents on the host, called via bash.
## agent_list
List available agents. Returns ID, name, description, and capabilities for each agent.
```bash
tai tool agent_list '{}'
tai tool agent_list '{"namespace": "smith"}'
```
| Parameter | Type | Required | Description |
|-------------|--------|----------|----------------------------------------------------------|
| `namespace` | string | no | Filter by namespace (e.g. `yao`, `smith`). Omit for all. |
## agent_download
Download a **smith-namespace** agent into the development directory for editing. **Restricted to `smith` namespace only** — for other agents, use `agent_reference`.
```bash
tai tool agent_download '{"id": "smith.weather"}'
```
| Parameter | Type | Required | Description |
|-----------|--------|----------|----------------------------------------------------------|
| `id` | string | yes | Agent ID in dot notation. Must be `smith.*`. |
Downloaded code lands in `agent-smith-dev/assistants/smith/<name>/`.
## agent_reference
Download agent source code from the host into `.references/` for **read-only study**. Any agent across all namespaces can be referenced.
```bash
tai tool agent_reference '{"id": "yao.slides"}'
tai tool agent_reference '{"id": "yao.keeper"}'
```
| Parameter | Type | Required | Description |
|-----------|--------|----------|----------------------------------------------------|
| `id` | string | yes | Agent ID in dot notation (e.g. `yao.slides`) |
Referenced code lands in `agent-smith-dev/.references/<namespace>/<name>/`.
## agent_deploy
Deploy agent source code from the sandbox development directory to the host. **Restricted to the `smith` namespace only** — attempts to deploy to other namespaces will be rejected.
```bash
tai tool agent_deploy '{"id": "smith.weather"}'
tai tool agent_deploy '{"id": "smith.weather", "message": "add SUI page"}'
```
| Parameter | Type | Required | Description |
|-----------|--------|----------|--------------------------------------------------------|
| `id` | string | yes | Agent ID in dot notation. Must use `smith` namespace. |
| `message` | string | no | Optional deploy message for logging. |
## agent_connectors
Get the current user's LLM connector matrix. Returns metadata for each role (default, heavy, light, vision, etc.) **without API keys**. Use this to understand which models are available and their capabilities.
```bash
tai tool agent_connectors '{}'
```
No parameters required.
## Guidelines
- Use `agent_list` to discover agents before downloading or referencing
- `agent_download` is for editing smith agents — code lands in `agent-smith-dev/assistants/smith/<name>/`
- `agent_reference` is for studying any agent — code lands in `agent-smith-dev/.references/<namespace>/<name>/`
- Deploy is restricted to the `smith` namespace for safety
- Connector data never includes API keys, secrets, or tokens
- All output is JSON

View file

@ -12,6 +12,7 @@ func TestSkillsFS_ContainsAllSkills(t *testing.T) {
"skills/yao-process/SKILL.md": false, "skills/yao-process/SKILL.md": false,
"skills/yao-doc/SKILL.md": false, "skills/yao-doc/SKILL.md": false,
"skills/yao-image/SKILL.md": false, "skills/yao-image/SKILL.md": false,
"skills/yao-agent/SKILL.md": false,
} }
err := fs.WalkDir(SkillsFS, "skills", func(path string, d fs.DirEntry, err error) error { err := fs.WalkDir(SkillsFS, "skills", func(path string, d fs.DirEntry, err error) error {
@ -43,6 +44,7 @@ func TestSkillsFS_FrontmatterFields(t *testing.T) {
{"skills/yao-process/SKILL.md", "yao-process"}, {"skills/yao-process/SKILL.md", "yao-process"},
{"skills/yao-doc/SKILL.md", "yao-doc"}, {"skills/yao-doc/SKILL.md", "yao-doc"},
{"skills/yao-image/SKILL.md", "yao-image"}, {"skills/yao-image/SKILL.md", "yao-image"},
{"skills/yao-agent/SKILL.md", "yao-agent"},
} }
for _, s := range skills { for _, s := range skills {

View file

@ -8,6 +8,7 @@ import (
mcpTypes "github.com/yaoapp/gou/mcp/types" mcpTypes "github.com/yaoapp/gou/mcp/types"
"github.com/yaoapp/gou/process" "github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/log" "github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/tools/agent"
"github.com/yaoapp/yao/tools/docs" "github.com/yaoapp/yao/tools/docs"
"github.com/yaoapp/yao/tools/image" "github.com/yaoapp/yao/tools/image"
"github.com/yaoapp/yao/tools/proc" "github.com/yaoapp/yao/tools/proc"
@ -27,6 +28,9 @@ var mcpDocDSL []byte
//go:embed mcps/image.json //go:embed mcps/image.json
var mcpImageDSL []byte var mcpImageDSL []byte
//go:embed mcps/agent.json
var mcpAgentDSL []byte
func init() { func init() {
process.RegisterGroup("tools", map[string]process.Handler{ process.RegisterGroup("tools", map[string]process.Handler{
"web_search": websearch.Handler, "web_search": websearch.Handler,
@ -39,6 +43,11 @@ func init() {
"image_read": image.ReadHandler, "image_read": image.ReadHandler,
"image_generate": image.GenerateHandler, "image_generate": image.GenerateHandler,
"image_providers": image.ProvidersHandler, "image_providers": image.ProvidersHandler,
"agent_list": agent.ListHandler,
"agent_download": agent.DownloadHandler,
"agent_reference": agent.ReferenceHandler,
"agent_deploy": agent.DeployHandler,
"agent_connectors": agent.ConnectorsHandler,
}) })
registerMCPServer(mcpWebDSL, "yao-web", registerMCPServer(mcpWebDSL, "yao-web",
@ -49,6 +58,9 @@ func init() {
docs.ListSchemaJSON, docs.InspectSchemaJSON, docs.ValidateSchemaJSON) docs.ListSchemaJSON, docs.InspectSchemaJSON, docs.ValidateSchemaJSON)
registerMCPServer(mcpImageDSL, "yao-image", registerMCPServer(mcpImageDSL, "yao-image",
image.ReadSchemaJSON, image.GenerateSchemaJSON, image.ProvidersSchemaJSON) image.ReadSchemaJSON, image.GenerateSchemaJSON, image.ProvidersSchemaJSON)
registerMCPServer(mcpAgentDSL, "yao-agent",
agent.ListSchemaJSON, agent.DownloadSchemaJSON, agent.ReferenceSchemaJSON,
agent.DeploySchemaJSON, agent.ConnectorsSchemaJSON)
} }
func registerMCPServer(dsl []byte, id string, schemas ...[]byte) { func registerMCPServer(dsl []byte, id string, schemas ...[]byte) {