Compare commits

..

11 commits

Author SHA1 Message Date
Max
cb95bb7871
Merge pull request #1534 from trheyi/main
feat(assistant): add hot-reload functionality for assistants and enhance gRPC metadata handling
2026-05-13 21:28:16 +08:00
Max
da4a803c11 feat(locale): enhance locale handling and agent functionalities
- Added locale support in various components, including the Assistant's execution context and agent tools, to improve internationalization.
- Updated the `pickNodeByFilter` function to include a fallback mechanism for host_exec nodes when no container nodes are available.
- Introduced a new `agent_reference` tool for downloading agent source code for read-only study, alongside updates to the `agent_download` tool to restrict downloads to the 'smith' namespace.
- Enhanced documentation and tests to reflect the new functionalities and ensure robust locale extraction and handling.
2026-05-13 21:25:49 +08:00
Max
639f0c59fc feat(assistant): add hot-reload functionality for assistants and enhance gRPC metadata handling
- Implemented AssistantReloadFunc to enable hot-reloading of assistants after deployment, improving deployment flexibility.
- Enhanced gRPC authProvider to include workspace and sandbox IDs from incoming context metadata, enriching the authentication context.
- Updated tools to support new agent-related functionalities, including listing, downloading, deploying, and connecting agents.
- Expanded system tools documentation to include new agent commands, ensuring comprehensive guidance for users.
2026-05-13 14:52:32 +08:00
Max
a0c4c543f3
Merge pull request #1533 from trheyi/main
refactor(robot_prompt): update prompt generation for robot configuration
2026-05-08 17:46:27 +08:00
Max
6af83149ef refactor(robot_prompt): update prompt generation for robot configuration
- Revised the system prompt to focus on generating and refining robot configurations rather than solely crafting prompts for autonomous robots.
- Enhanced input context details to include specific fields relevant to robot configuration, improving clarity and usability.
- Streamlined output rules to ensure a pure JSON object is returned, aligning with best practices for API responses.
- Removed outdated examples and sections to simplify the prompt structure and improve overall coherence.
2026-05-08 17:45:22 +08:00
Max
59bf6ddc8a
Merge pull request #1532 from trheyi/main
feat(workspace): enhance attachment handling and execution context
2026-05-08 11:27:56 +08:00
Max
421d946971 feat(buffer): refine step retrieval for resume functionality
- Updated the GetStepsForResume method to return only steps with valid resume statuses (failed or interrupted), improving recovery context handling.
- Enhanced error handling in message processing across various integrations, including friendly error messages for users in different locales.
- Modified file handling in message integrations (Dingtalk, Discord, Feishu, Telegram, Weixin) to differentiate between image and file types, ensuring appropriate content delivery.
2026-05-08 10:14:26 +08:00
Max
4376ac9dad feat(weixin): add workspace file handling and MIME type detection
- Implemented support for reading files from workspace URIs in the Weixin reply process, enhancing attachment management.
- Introduced a new `mimeFromExt` function to determine content types based on file extensions, improving file handling capabilities.
- Updated the `sendMediaFromURL` method to accommodate workspace file retrieval, ensuring robust error handling and logging for file operations.
2026-05-07 20:28:28 +08:00
Max
f230f1e90c feat(workspace): enhance attachment handling and execution context
- Added support for reading files from workspace URIs in the delivery process, allowing for more flexible attachment management.
- Introduced a new `convertWorkspaceAttachment` function to handle workspace-based file retrieval and integration into messenger attachments.
- Updated the `AgentCaller` to include execution mode in the context, improving task execution tracking.
- Enhanced the `RunDelivery` method to utilize workspace manifests for delivery input, reducing token usage and improving efficiency.
- Implemented locale handling in various request structures to support multi-language capabilities in user interfaces.
2026-05-07 19:32:48 +08:00
Max
a01eb869ad
Merge pull request #1531 from trheyi/main
feat(claude): implement user ID hashing and enhance environment setup
2026-05-06 18:37:56 +08:00
Max
cc078b953a feat(claude): implement user ID hashing and enhance environment setup
- Added a `hashUserID` function to generate a collision-safe hash for user IDs, improving metadata handling.
- Updated the environment setup in `buildEnv` to override `metadata.user_id` with a sanitized value, ensuring compatibility with third-party APIs.
- Enhanced logging for the environment variables to aid in debugging and monitoring.
2026-05-06 18:36:47 +08:00
65 changed files with 4256 additions and 617 deletions

View file

@ -3,6 +3,7 @@ package assistant
import (
"fmt"
"path"
"strings"
"github.com/yaoapp/gou/fs"
"github.com/yaoapp/yao/agent/caller"
@ -27,6 +28,28 @@ func init() {
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
caller.SetJSAPIFactory()

View file

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

View file

@ -15,3 +15,7 @@ type AgentCaller interface {
// AgentGetterFunc is a function type that gets an agent by ID
// This should be set by the assistant package during initialization
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

@ -382,9 +382,14 @@ func (b *ChatBuffer) GetStepsForResume(finalStatus string) []*BufferedStep {
b.currentStep.Status = finalStatus
}
// Return all steps (they will all have the context for recovery)
result := make([]*BufferedStep, len(b.steps))
copy(result, b.steps)
// Only return steps with valid resume status (failed or interrupted)
result := make([]*BufferedStep, 0, len(b.steps))
for _, step := range b.steps {
if step.Status != ResumeStatusFailed && step.Status != ResumeStatusInterrupted {
continue
}
result = append(result, step)
}
return result
}

View file

@ -498,10 +498,10 @@ func TestBufferGetStepsForResume(t *testing.T) {
steps := buffer.GetStepsForResume(context.ResumeStatusFailed)
require.NotNil(t, steps)
assert.Len(t, steps, 2)
assert.Len(t, steps, 1)
// Current step should be marked as failed
assert.Equal(t, context.ResumeStatusFailed, steps[1].Status)
// Only the failed step should be returned
assert.Equal(t, context.ResumeStatusFailed, steps[0].Status)
})
t.Run("InterruptedRequest", func(t *testing.T) {
@ -516,8 +516,8 @@ func TestBufferGetStepsForResume(t *testing.T) {
steps := buffer.GetStepsForResume(context.ResumeStatusInterrupted)
require.NotNil(t, steps)
assert.Len(t, steps, 3)
assert.Equal(t, context.ResumeStatusInterrupted, steps[2].Status)
assert.Len(t, steps, 1)
assert.Equal(t, context.ResumeStatusInterrupted, steps[0].Status)
})
}
@ -1014,10 +1014,10 @@ func TestBufferCompleteWorkflow(t *testing.T) {
// Get steps for resume
steps := buffer.GetStepsForResume(context.ResumeStatusInterrupted)
require.NotNil(t, steps)
assert.Len(t, steps, 2)
assert.Len(t, steps, 1)
// Last step should be interrupted with space snapshot
lastStep := steps[len(steps)-1]
// Only the interrupted step should be returned
lastStep := steps[0]
assert.Equal(t, context.ResumeStatusInterrupted, lastStep.Status)
assert.NotNil(t, lastStep.SpaceSnapshot)
assert.Equal(t, "previous conversation", lastStep.SpaceSnapshot["user_context"])

View file

@ -111,6 +111,7 @@ func triggerHuman(ctx *types.Context, mgr managerInterface, memberID string, req
Messages: req.Messages,
PlanTime: req.PlanAt,
ExecutorMode: req.ExecutorMode,
Locale: req.Locale,
}
// Call manager's Intervene

View file

@ -13,6 +13,8 @@ import (
"strings"
"time"
"path/filepath"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/gou/text"
agentcontext "github.com/yaoapp/yao/agent/context"
@ -21,6 +23,7 @@ import (
eventtypes "github.com/yaoapp/yao/event/types"
"github.com/yaoapp/yao/messenger"
messengerTypes "github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/workspace"
)
// handleDelivery routes delivery content to configured channels (email, webhook, process).
@ -432,6 +435,15 @@ func convertAttachments(ctx context.Context, attachments []robottypes.DeliveryAt
result := make([]messengerTypes.Attachment, 0, len(attachments))
for _, att := range attachments {
// Handle workspace:// URIs — read file content from workspace FS
if strings.HasPrefix(att.File, "workspace://") {
wsAtt := convertWorkspaceAttachment(ctx, att)
if wsAtt != nil {
result = append(result, *wsAtt)
}
continue
}
uploader, fileID, isWrapper := attachment.Parse(att.File)
if !isWrapper {
log.Warn("convertAttachments: skipping non-wrapper file value=%q title=%q", att.File, att.Title)
@ -481,6 +493,87 @@ func convertAttachments(ctx context.Context, attachments []robottypes.DeliveryAt
return result
}
// convertWorkspaceAttachment reads a file from workspace:// URI and returns a messenger attachment.
// URI format: workspace://<wsID>/<path>
func convertWorkspaceAttachment(ctx context.Context, att robottypes.DeliveryAttachment) *messengerTypes.Attachment {
uri := att.File
// Strip "workspace://" prefix
rest := strings.TrimPrefix(uri, "workspace://")
slashIdx := strings.Index(rest, "/")
if slashIdx < 0 {
log.Warn("convertWorkspaceAttachment: invalid URI %q — no path after wsID", uri)
return nil
}
wsID := rest[:slashIdx]
filePath := rest[slashIdx+1:]
if wsID == "" || filePath == "" {
log.Warn("convertWorkspaceAttachment: empty wsID or path in URI %q", uri)
return nil
}
wsm := workspace.M()
if wsm == nil {
log.Warn("convertWorkspaceAttachment: workspace manager not available for URI %q", uri)
return nil
}
wsFS, err := wsm.FS(ctx, wsID)
if err != nil {
log.Warn("convertWorkspaceAttachment: cannot get FS for workspace %q: %v", wsID, err)
return nil
}
content, err := wsFS.ReadFile(filePath)
if err != nil {
log.Warn("convertWorkspaceAttachment: failed to read %q from workspace %q: %v", filePath, wsID, err)
return nil
}
filename := filepath.Base(filePath)
if att.Title != "" {
filename = att.Title
}
contentType := mimeFromExtDelivery(filepath.Ext(filename))
log.Info("convertWorkspaceAttachment: added workspace attachment filename=%q contentType=%q size=%d uri=%q",
filename, contentType, len(content), uri)
return &messengerTypes.Attachment{
Filename: filename,
ContentType: contentType,
Content: content,
}
}
func mimeFromExtDelivery(ext string) string {
switch strings.ToLower(ext) {
case ".pdf":
return "application/pdf"
case ".html", ".htm":
return "text/html"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".csv":
return "text/csv"
case ".json":
return "application/json"
case ".md":
return "text/markdown"
case ".txt":
return "text/plain"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
default:
return "application/octet-stream"
}
}
func attachmentManagerKeys() []string {
keys := make([]string, 0, len(attachment.Managers))
for k := range attachment.Managers {

View file

@ -91,12 +91,23 @@ func buildContentParts(cm *dtapi.ConvertedMessage) []interface{} {
if url == "" {
url = mi.URL
}
parts = append(parts, map[string]interface{}{
"type": "file",
"file_url": url,
"mime_type": mi.MimeType,
"file_name": mi.FileName,
})
if strings.HasPrefix(mi.MimeType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": url,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{
"type": "file",
"file": map[string]interface{}{
"url": url,
"filename": mi.FileName,
},
})
}
}
return parts

View file

@ -96,12 +96,23 @@ func buildContentParts(cm *dcapi.ConvertedMessage) []interface{} {
if url == "" {
continue
}
parts = append(parts, map[string]interface{}{
"type": "file",
"file_url": url,
"mime_type": mi.ContentType,
"file_name": mi.FileName,
})
if strings.HasPrefix(mi.ContentType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": url,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{
"type": "file",
"file": map[string]interface{}{
"url": url,
"filename": mi.FileName,
},
})
}
}
return parts

View file

@ -85,12 +85,23 @@ func buildContentParts(cm *fsapi.ConvertedMessage) []interface{} {
if mi.Wrapper == "" {
continue
}
parts = append(parts, map[string]interface{}{
"type": "file",
"file_url": mi.Wrapper,
"mime_type": mi.MimeType,
"file_name": mi.FileName,
})
if strings.HasPrefix(mi.MimeType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": mi.Wrapper,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{
"type": "file",
"file": map[string]interface{}{
"url": mi.Wrapper,
"filename": mi.FileName,
},
})
}
}
return parts

View file

@ -93,12 +93,23 @@ func buildContentParts(cm *tgapi.ConvertedMessage) []interface{} {
if mi.Wrapper == "" {
continue
}
parts = append(parts, map[string]interface{}{
"type": "file",
"file_url": mi.Wrapper,
"mime_type": mi.MimeType,
"file_name": mi.FileName,
})
if strings.HasPrefix(mi.MimeType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": mi.Wrapper,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{
"type": "file",
"file": map[string]interface{}{
"url": mi.Wrapper,
"filename": mi.FileName,
},
})
}
}
return parts

View file

@ -3,6 +3,7 @@ package weixin
import (
"context"
"fmt"
"strings"
"time"
agentcontext "github.com/yaoapp/yao/agent/context"
@ -113,12 +114,23 @@ func (a *Adapter) handleMessage(ctx context.Context, entry *botEntry, msg *weixi
parts = append(parts, map[string]interface{}{"type": "text", "text": content})
}
for _, m := range mediaItems {
parts = append(parts, map[string]interface{}{
"type": "file",
"file_url": m.Wrapper,
"mime_type": m.MimeType,
"file_name": m.FileName,
})
if strings.HasPrefix(m.MimeType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": m.Wrapper,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{
"type": "file",
"file": map[string]interface{}{
"url": m.Wrapper,
"filename": m.FileName,
},
})
}
}
msgContent = parts
}

View file

@ -5,12 +5,14 @@ import (
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
agentcontext "github.com/yaoapp/yao/agent/context"
events "github.com/yaoapp/yao/agent/robot/events"
"github.com/yaoapp/yao/attachment"
weixinapi "github.com/yaoapp/yao/integrations/weixin"
"github.com/yaoapp/yao/workspace"
)
func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata *events.MessageMetadata) error {
@ -168,7 +170,37 @@ func (a *Adapter) sendMediaFromURL(ctx context.Context, entry *botEntry, toUserI
var plaintext []byte
var contentType string
if isWrapper(fileURL) {
if strings.HasPrefix(fileURL, "workspace://") {
rest := strings.TrimPrefix(fileURL, "workspace://")
slashIdx := strings.Index(rest, "/")
if slashIdx < 0 {
return fmt.Errorf("invalid workspace URL: %s", fileURL)
}
wsID := rest[:slashIdx]
filePath := rest[slashIdx+1:]
wsm := workspace.M()
if wsm == nil {
return fmt.Errorf("workspace manager not initialized")
}
wsFS, err := wsm.FS(ctx, wsID)
if err != nil {
return fmt.Errorf("open workspace %s: %w", wsID, err)
}
defer wsFS.Close()
plaintext, err = wsFS.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read workspace file %s/%s: %w", wsID, filePath, err)
}
contentType = mimeFromExt(filepath.Ext(filePath))
if fileName == "" {
fileName = filepath.Base(filePath)
}
log.Info("weixin sendMedia: workspace read bytes=%d contentType=%q fileName=%q", len(plaintext), contentType, fileName)
} else if isWrapper(fileURL) {
managerName, fileID, err := parseWrapper(fileURL)
if err != nil {
return err
@ -291,6 +323,37 @@ func toContentParts(content interface{}) ([]agentcontext.ContentPart, bool) {
return parts, ok
}
func mimeFromExt(ext string) string {
switch strings.ToLower(ext) {
case ".pdf":
return "application/pdf"
case ".html", ".htm":
return "text/html"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".md":
return "text/markdown"
case ".txt":
return "text/plain"
case ".csv":
return "text/csv"
case ".json":
return "application/json"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
default:
return "application/octet-stream"
}
}
func (a *Adapter) resolveByAccountID(accountID string) *botEntry {
if accountID == "" {
return nil

View file

@ -33,6 +33,15 @@ func (h *robotHandler) handleMessage(ctx context.Context, ev *eventtypes.Event,
result, err := callHostAgent(ctx, &payload)
if err != nil {
log.Error("message handler: host agent call failed robot=%s: %v", payload.RobotID, err)
if reply := getReplyFunc(); reply != nil {
errMsg := friendlyErrorMessage(payload.Metadata.Locale)
_ = reply(ctx, &agentcontext.Message{
Role: agentcontext.RoleAssistant,
Content: errMsg,
}, payload.Metadata)
}
if ev.IsCall {
resp <- eventtypes.Result{Err: err}
}
@ -73,6 +82,7 @@ func callHostAgent(ctx context.Context, payload *MessagePayload) (*MessageResult
authorized := &oauthtypes.AuthorizedInfo{
UserID: payload.Metadata.SenderID,
TeamID: record.TeamID,
}
chatID := fmt.Sprintf("%s:%s", payload.Metadata.Channel, payload.Metadata.ChatID)
agentCtx := agentcontext.New(ctx, authorized, chatID)
@ -232,6 +242,13 @@ func resolveHostAssistantID(ctx context.Context, memberID string) (string, *robo
return hostID, record, nil
}
func friendlyErrorMessage(locale string) string {
if strings.HasPrefix(locale, "zh") {
return "抱歉,处理您的消息时出现了问题,请稍后重试。"
}
return "Sorry, there was a problem processing your message. Please try again later."
}
func taskDeployedMessage(execID string, locale string) string {
if strings.HasPrefix(locale, "zh") {
return fmt.Sprintf("任务已部署(执行编号: %s完成后会将结果发送给你。", execID)

View file

@ -53,6 +53,11 @@ type AgentCaller struct {
// When non-empty, injected into agentCtx.Metadata["workspace_id"] for sandbox node resolution.
Workspace string
// Mode is the agent execution mode (e.g., "task" for robot P3 execution).
// When non-empty, injected into agentCtx.Metadata["mode"] (exposed as $CTX.MODE
// in prompt templates) and into opts.Mode for framework-level buffer/chat recording.
Mode string
// log is an optional structured logger; when set, Call emits agent-call logs.
log *execLogger
}
@ -194,6 +199,7 @@ func (c *AgentCaller) Call(ctx *robottypes.Context, assistantID string, messages
Search: c.SkipSearch,
},
Connector: c.Connector,
Mode: c.Mode,
}
agentCtx := c.buildAgentContext(ctx, assistantID)
@ -228,7 +234,7 @@ func (c *AgentCaller) Call(ctx *robottypes.Context, assistantID string, messages
}
if c.log != nil {
c.log.logAgentCall(assistantID, result)
c.log.logAgentCall(assistantID, c.Connector, result)
}
return result, nil
@ -276,6 +282,7 @@ func (c *AgentCaller) CallStream(ctx *robottypes.Context, assistantID string, me
Search: c.SkipSearch,
},
Connector: c.Connector,
Mode: c.Mode,
}
// Hook OnMessage to intercept streaming chunks and forward to callback
@ -332,7 +339,7 @@ func (c *AgentCaller) CallStream(ctx *robottypes.Context, assistantID string, me
}
if c.log != nil {
c.log.logAgentCall(assistantID, result)
c.log.logAgentCall(assistantID, c.Connector, result)
}
return result, nil
@ -366,6 +373,7 @@ func (c *AgentCaller) CallStreamRaw(ctx *robottypes.Context, assistantID string,
Search: c.SkipSearch,
},
Connector: c.Connector,
Mode: c.Mode,
}
if onMessage != nil {
@ -400,7 +408,7 @@ func (c *AgentCaller) CallStreamRaw(ctx *robottypes.Context, assistantID string,
}
if c.log != nil {
c.log.logAgentCall(assistantID, result)
c.log.logAgentCall(assistantID, c.Connector, result)
}
return result, nil
@ -448,11 +456,16 @@ func (c *AgentCaller) buildAgentContext(ctx *robottypes.Context, assistantID str
}
agentCtx.Logger = agentcontext.Noop()
if c.Workspace != "" {
if c.Workspace != "" || c.Mode != "" {
if agentCtx.Metadata == nil {
agentCtx.Metadata = map[string]interface{}{}
}
agentCtx.Metadata["workspace_id"] = c.Workspace
if c.Workspace != "" {
agentCtx.Metadata["workspace_id"] = c.Workspace
}
if c.Mode != "" {
agentCtx.Metadata["MODE"] = c.Mode
}
}
kunlog.Trace("[robot-agent] context built: assistantID=%s chatID=%s contextID=%s", assistantID, c.ChatID, agentCtx.ID)

View file

@ -3,6 +3,7 @@ package standard
import (
"encoding/json"
"fmt"
"path"
"strings"
"time"
@ -12,6 +13,7 @@ import (
robotevents "github.com/yaoapp/yao/agent/robot/events"
robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/event"
"github.com/yaoapp/yao/workspace"
)
// RunDelivery executes P4: Delivery phase
@ -36,14 +38,36 @@ func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Executi
}
formatter := NewInputFormatter()
userContent := formatter.FormatDeliveryInput(exec, robot)
// Try workspace-based delivery input (manifest summaries instead of raw output inline)
var userContent string
if robot.Workspace != "" {
wsm := workspace.M()
if wsm != nil {
wsFS, err := wsm.FS(ctx, robot.Workspace)
if err == nil {
execDir := path.Join("robots", robot.MemberID, exec.ID)
data, err := wsFS.ReadFile(path.Join(execDir, "manifest.json"))
if err == nil {
var manifest Manifest
if json.Unmarshal(data, &manifest) == nil {
userContent = formatter.FormatDeliveryInputWithManifest(exec, robot, &manifest, robot.Workspace, execDir, locale)
}
}
}
}
}
// Fallback to legacy full-output inline
if userContent == "" {
userContent = formatter.FormatDeliveryInput(exec, robot)
}
if userContent == "" {
return fmt.Errorf("no content available for delivery generation")
}
caller := NewAgentCaller()
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil {
@ -413,3 +437,103 @@ func (f *InputFormatter) FormatDeliveryInput(exec *robottypes.Execution, robot *
return sb.String()
}
// FormatDeliveryInputWithManifest formats delivery input using workspace manifest summaries
// instead of inlining full task outputs. This drastically reduces token usage.
func (f *InputFormatter) FormatDeliveryInputWithManifest(
exec *robottypes.Execution,
robot *robottypes.Robot,
manifest *Manifest,
wsID string,
execDir string,
locale string,
) string {
if exec == nil || manifest == nil {
return ""
}
var sb strings.Builder
// Robot Identity (same as legacy)
if robot != nil && robot.Config != nil && robot.Config.Identity != nil {
sb.WriteString("## Robot Identity\n\n")
sb.WriteString(fmt.Sprintf("- **Role**: %s\n", robot.Config.Identity.Role))
if len(robot.Config.Identity.Duties) > 0 {
sb.WriteString("- **Duties**: ")
sb.WriteString(strings.Join(robot.Config.Identity.Duties, ", "))
sb.WriteString("\n")
}
sb.WriteString("\n")
}
sb.WriteString("## Execution Context\n\n")
sb.WriteString(fmt.Sprintf("- **Trigger**: %s\n", exec.TriggerType))
sb.WriteString(fmt.Sprintf("- **Status**: %s\n", exec.Status))
if locale != "" {
sb.WriteString(fmt.Sprintf("- **Language**: %s\n", locale))
}
sb.WriteString(fmt.Sprintf("- **Start Time**: %s\n", exec.StartTime.Format("2006-01-02 15:04:05")))
if exec.EndTime != nil {
duration := exec.EndTime.Sub(exec.StartTime)
sb.WriteString(fmt.Sprintf("- **Duration**: %s\n", duration.String()))
}
sb.WriteString("\n")
sb.WriteString("## Goals\n\n")
sb.WriteString(manifest.Goals)
sb.WriteString("\n\n")
// Results from manifest summaries
sb.WriteString("## Results (P3)\n\n")
successCount := 0
failCount := 0
for _, t := range manifest.Tasks {
if t.Status == "completed" {
successCount++
sb.WriteString(fmt.Sprintf("### ✓ Task: %s\n\n", t.ID))
} else if t.Status == "failed" {
failCount++
sb.WriteString(fmt.Sprintf("### ✗ Task: %s\n\n", t.ID))
} else {
continue
}
sb.WriteString(fmt.Sprintf("- **Description**: %s\n", t.Description))
sb.WriteString(fmt.Sprintf("- **Executor**: %s (%s)\n", t.Executor, t.ExecutorType))
if t.Summary != "" {
sb.WriteString(fmt.Sprintf("- **Summary**: %s\n", t.Summary))
}
if len(t.KeyOutputs) > 0 {
sb.WriteString(fmt.Sprintf("- **Key Outputs**: %s\n", strings.Join(t.KeyOutputs, ", ")))
}
// File references — use existing URI if present, otherwise build from execDir
if len(t.Files) > 0 {
sb.WriteString("- **Artifacts**:\n")
for _, file := range t.Files {
uri := file.URI
if uri == "" {
uri = fmt.Sprintf("workspace://%s/%s/%s/%s", wsID, execDir, t.ID, file.Name)
}
sb.WriteString(fmt.Sprintf(" - [%s](%s)", file.Name, uri))
if file.Desc != "" {
sb.WriteString(fmt.Sprintf(" — %s", file.Desc))
}
sb.WriteString("\n")
}
}
// Full output file reference
outputURI := fmt.Sprintf("workspace://%s/%s/%s.output.md", wsID, execDir, t.ID)
sb.WriteString(fmt.Sprintf("- **Full Output**: [%s.output.md](%s)\n", t.ID, outputURI))
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("### Summary\n\n- **Total Tasks**: %d\n- **Succeeded**: %d\n- **Failed**: %d\n\n",
successCount+failCount, successCount, failCount))
return sb.String()
}

View file

@ -84,7 +84,6 @@ func (e *Executor) RunGoals(ctx *robottypes.Context, exec *robottypes.Execution,
// Call agent
caller := NewAgentCaller()
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil {

View file

@ -32,7 +32,6 @@ func (e *Executor) CallHostAgent(ctx *robottypes.Context, robot *robottypes.Robo
kunlog.Info("calling Host Agent %s for scenario=%s chatID=%s", agentID, input.Scenario, chatID)
caller := NewConversationCaller(chatID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, string(inputJSON))
if err != nil {

View file

@ -183,6 +183,7 @@ func (f *InputFormatter) FormatAvailableResourcesWithLocale(robot *robottypes.Ro
capabilities := i18n.Translate(agentID, locale, ast.Capabilities).(string)
sb.WriteString(fmt.Sprintf(" - **Capabilities**: %s\n", capabilities))
}
}
sb.WriteString("\n")
}

View file

@ -53,7 +53,6 @@ func (e *Executor) RunInspiration(ctx *robottypes.Context, exec *robottypes.Exec
// Call agent
caller := NewAgentCaller()
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil {

View file

@ -103,9 +103,9 @@ func (l *execLogger) devTaskOverview(tasks []robottypes.Task) {
// P3: Task Input
// ---------------------------------------------------------------------------
func (l *execLogger) logTaskInput(task *robottypes.Task, prompt string) {
func (l *execLogger) logTaskInput(task *robottypes.Task, prompt string, actualConnector string) {
if config.IsDevelopment() {
l.devTaskInput(task, prompt)
l.devTaskInput(task, prompt, actualConnector)
}
kunlog.With(kunlog.F{
"robot_id": l.robotID(),
@ -115,17 +115,23 @@ func (l *execLogger) logTaskInput(task *robottypes.Task, prompt string) {
"executor_id": task.ExecutorID,
"prompt_len": len(prompt),
"language_model": l.connector(),
"connector": actualConnector,
}).Info("Task input: %s [%s]", task.ID, task.ExecutorID)
}
func (l *execLogger) devTaskInput(task *robottypes.Task, prompt string) {
func (l *execLogger) devTaskInput(task *robottypes.Task, prompt string, actualConnector string) {
w := logger.Gray
v := logger.White
r := logger.Reset
connLabel := actualConnector
if connLabel == "" {
connLabel = "agent-default"
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s ▶ Task %s%s%s [%s:%s] Prompt: %d chars%s\n",
w, v, task.ID, w, task.ExecutorType, task.ExecutorID, len(prompt), r))
sb.WriteString(fmt.Sprintf("%s ▶ Task %s%s%s [%s:%s] Connector: %s%s%s Prompt: %d chars%s\n",
w, v, task.ID, w, task.ExecutorType, task.ExecutorID, v, connLabel, w, len(prompt), r))
logger.Raw(sb.String())
}
@ -190,18 +196,19 @@ func (l *execLogger) devTaskOutput(task *robottypes.Task, result *robottypes.Tas
// Agent Call
// ---------------------------------------------------------------------------
func (l *execLogger) logAgentCall(agentID string, result *CallResult) {
func (l *execLogger) logAgentCall(agentID string, connector string, result *CallResult) {
if result == nil {
return
}
if config.IsDevelopment() {
l.devAgentCall(agentID, result)
l.devAgentCall(agentID, connector, result)
}
fields := kunlog.F{
"robot_id": l.robotID(),
"execution_id": l.execID,
"agent_id": agentID,
"connector": connector,
"content_len": len(result.Content),
"language_model": l.connector(),
}
@ -209,23 +216,28 @@ func (l *execLogger) logAgentCall(agentID string, result *CallResult) {
fields["next_type"] = fmt.Sprintf("%T", result.Next)
fields["next_len"] = outputLen(result.Next)
}
kunlog.With(fields).Info("Agent call: %s (content=%d, next=%T)", agentID, len(result.Content), result.Next)
kunlog.With(fields).Info("Agent call: %s (connector=%s, content=%d, next=%T)", agentID, connector, len(result.Content), result.Next)
}
func (l *execLogger) devAgentCall(agentID string, result *CallResult) {
func (l *execLogger) devAgentCall(agentID string, connector string, result *CallResult) {
w := logger.Gray
v := logger.White
c := logger.Cyan
r := logger.Reset
displayConn := connector
if displayConn == "" {
displayConn = "agent-default"
}
nextInfo := "—"
if result.Next != nil {
nextInfo = fmt.Sprintf("%T (len=%d)", result.Next, outputLen(result.Next))
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s → Agent(%s%s%s) Content: %s%d%s chars Next: %s%s%s\n",
c, v, agentID, c, v, len(result.Content), w, v, nextInfo, r))
sb.WriteString(fmt.Sprintf("%s → Agent(%s%s%s) Connector: %s%s%s Content: %s%d%s chars Next: %s%s%s\n",
c, v, agentID, c, v, displayConn, c, v, len(result.Content), w, v, nextInfo, r))
logger.Raw(sb.String())
}

View file

@ -0,0 +1,60 @@
workspace: |
## Workspace
Execution directory: {{.ExecDir}}
### Available Files
{{range .Files}}
- {{.Path}} — {{.Desc}}
{{- end}}
### Your Task Directory
Write any output files to: {{.TaskDir}}
context: |
## Execution Context
Goals: {{.Goals}}
{{- if .Locale}}
Language: {{.Locale}}
{{- end}}
Mode: automated-pipeline
{{- if .FailureWarning}}
⚠ {{.FailureWarning}}
{{- end}}
{{if .CompletedTasks}}
### Completed Tasks
{{range .CompletedTasks}}
{{.Seq}}. [{{.ID}}] {{.Description}} ({{.ExecutorType}}: {{.Executor}}) ✓
Summary: {{.Summary}}
{{- if .KeyOutputs}}
Key outputs: {{.KeyOutputs}}
{{- end}}
{{- if .HasFiles}}
Files: {{.Files}}
{{- end}}
{{end}}
{{- end}}
{{- if .FailedTasks}}
### Failed Tasks
{{range .FailedTasks}}
{{.Seq}}. [{{.ID}}] {{.Description}} ✗
Error: {{.Error}}
{{end}}
{{- end}}
### Current Task
{{.CurrentOrder}}. [{{.CurrentID}}] {{.CurrentDesc}} ({{.CurrentType}}: {{.CurrentExec}})
instructions: |
## Task Instructions
{{.TaskInstructions}}
{{- if .ExpectedOutput}}
### Expected Output
{{.ExpectedOutput}}
{{- end}}

View file

@ -2,8 +2,10 @@ package standard
import (
"fmt"
"path"
"time"
kunlog "github.com/yaoapp/kun/log"
robotevents "github.com/yaoapp/yao/agent/robot/events"
robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/event"
@ -53,9 +55,6 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
config = DefaultRunConfig()
}
// Determine locale for UI messages
locale := getEffectiveLocale(robot, exec.Input)
// Determine start index and restore results from resume context
startIndex := 0
if exec.ResumeContext != nil {
@ -67,6 +66,26 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
// Create task runner with execution-level chatID (§8.4)
runner := NewRunner(ctx, robot, config, exec.ChatID, exec.ID)
if ctx.Locale != "" {
runner.locale = ctx.Locale
} else {
runner.locale = getEffectiveLocale(robot, exec.Input)
}
// Initialize workspace for file-based context
wsFS, err := ensureRobotWorkspace(ctx, robot)
if err != nil {
kunlog.Warn("[robot-run] workspace unavailable, falling back to in-memory context: %v", err)
} else {
execDir := path.Join("robots", robot.MemberID, exec.ID)
if mkErr := wsFS.MkdirAll(execDir, 0755); mkErr != nil {
kunlog.Warn("[robot-run] mkdir %s: %v", execDir, mkErr)
} else {
runner.wsFS = wsFS
runner.execDir = execDir
runner.initManifest(exec)
}
}
// Execute tasks sequentially from startIndex
for i := startIndex; i < len(exec.Tasks); i++ {
@ -80,7 +99,7 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
}
// Update UI field with current task description (i18n)
taskName := formatTaskProgressName(task, i, len(exec.Tasks), locale)
taskName := formatTaskProgressName(task, i, len(exec.Tasks), runner.locale)
e.updateUIFields(ctx, exec, "", taskName)
// Mark task as running
@ -127,7 +146,10 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
})
}
// Store result
// Write task files to workspace (non-blocking, errors logged)
runner.writeTaskOutput(task, result, runner.lastPromptSnapshot)
// Store result (in-memory, for persistence + resume)
exec.Results = append(exec.Results, *result)
// Persist completed/failed state to database

View file

@ -10,16 +10,24 @@ import (
"github.com/yaoapp/gou/process"
kunlog "github.com/yaoapp/kun/log"
agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/llm"
robottypes "github.com/yaoapp/yao/agent/robot/types"
taiworkspace "github.com/yaoapp/yao/tai/workspace"
)
// Runner handles execution of individual tasks
type Runner struct {
ctx *robottypes.Context
robot *robottypes.Robot
config *RunConfig
chatID string // execution-level chatID for conversation persistence (§8.4)
log *execLogger
ctx *robottypes.Context
robot *robottypes.Robot
config *RunConfig
chatID string // execution-level chatID for conversation persistence (§8.4)
log *execLogger
wsFS taiworkspace.FS // workspace file system (nil if unavailable)
execDir string // workspace-relative execution directory (e.g. "robots/<id>/<exec_id>")
lastPromptSnapshot string // captured prompt text for workspace .input.md
currentTaskIndex int // current task index for workspace prompt building
currentExec *robottypes.Execution
locale string // effective locale for this execution (e.g. "zh", "en")
}
// NewRunner creates a new task runner
@ -47,12 +55,15 @@ type RunnerContext struct {
// BuildTaskContext builds context for a task including previous results
func (r *Runner) BuildTaskContext(exec *robottypes.Execution, taskIndex int) *RunnerContext {
r.currentTaskIndex = taskIndex
r.currentExec = exec
ctx := &RunnerContext{
Goals: exec.Goals,
SystemPrompt: r.robot.SystemPrompt,
}
// Include results from previous tasks (with bounds check)
// Include results from previous tasks (with bounds check) — kept for fallback
if taskIndex > 0 && len(exec.Results) > 0 {
endIndex := taskIndex
if endIndex > len(exec.Results) {
@ -132,26 +143,56 @@ func (r *Runner) executeNonAssistantTask(task *robottypes.Task, taskCtx *RunnerC
// Returns the extracted output, the raw CallResult (for need_input detection), and any error.
func (r *Runner) executeAssistantTask(task *robottypes.Task, taskCtx *RunnerContext) (interface{}, *CallResult, error) {
caller := NewAgentCaller()
caller.Mode = "task"
caller.log = r.log
caller.Connector = r.robot.LanguageModel
if r.robot.LanguageModel != "" {
if _, _, err := llm.ResolveConnector(r.robot.LanguageModel, nil); err == nil {
caller.Connector = r.robot.LanguageModel
} else {
kunlog.Warn("[robot-runner] connector %s invalid, using agent default: %v",
r.robot.LanguageModel, err)
}
}
caller.Workspace = r.robot.Workspace
caller.ChatID = r.chatID
messages := r.BuildAssistantMessages(task, taskCtx)
input := r.FormatMessagesAsText(messages)
var input string
workspacePromptUsed := false
// Use workspace-based prompt when available
if r.wsFS != nil {
manifest, err := r.readManifest()
if err == nil {
taskInstructions := r.FormatMessagesAsText(task.Messages)
input = r.buildWorkspacePrompt(manifest, r.currentTaskIndex, task, taskInstructions)
workspacePromptUsed = (input != "")
}
}
// Fallback to legacy in-memory context
if input == "" {
messages := r.BuildAssistantMessages(task, taskCtx)
input = r.FormatMessagesAsText(messages)
}
if strings.TrimSpace(input) == "" {
return nil, nil, fmt.Errorf("no valid input messages for task %s", task.ID)
}
if taskCtx.SystemPrompt != "" {
// Only inject robot system prompt for legacy (non-workspace) path.
// In workspace mode the agent's own prompts.yml defines its role;
// injecting the robot's dispatcher prompt would confuse the executor.
if taskCtx.SystemPrompt != "" && !workspacePromptUsed {
input = "## Context\n\n" + taskCtx.SystemPrompt + "\n\n## Task\n\n" + input
}
// Capture prompt snapshot for workspace .input.md
r.lastPromptSnapshot = input
kunlog.Trace("[robot-runner] executeAssistantTask: task=%s assistant=%s promptLen=%d prevResults=%d",
task.ID, task.ExecutorID, len(input), len(taskCtx.PreviousResults))
r.log.logTaskInput(task, input)
r.log.logTaskInput(task, input, caller.Connector)
result, err := caller.CallWithMessages(r.ctx, task.ExecutorID, input)
if err != nil {

View file

@ -2,7 +2,9 @@ package standard
import (
"fmt"
"strings"
kunlog "github.com/yaoapp/kun/log"
agentcontext "github.com/yaoapp/yao/agent/context"
robottypes "github.com/yaoapp/yao/agent/robot/types"
)
@ -54,7 +56,6 @@ func (e *Executor) RunTasks(ctx *robottypes.Context, exec *robottypes.Execution,
// Call agent
caller := NewAgentCaller()
caller.log = newExecLogger(robot, exec.ID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil {
@ -80,6 +81,9 @@ func (e *Executor) RunTasks(ctx *robottypes.Context, exec *robottypes.Execution,
return fmt.Errorf("tasks agent (%s) returned invalid task structure: %w", agentID, err)
}
// Normalize executor IDs and types against available resources
NormalizeTaskExecutors(tasks, robot)
// Validate tasks
if err := ValidateTasks(tasks); err != nil {
return fmt.Errorf("tasks validation failed: %w", err)
@ -395,3 +399,81 @@ func ValidateMCPTask(task *robottypes.Task) error {
}
return nil
}
// NormalizeTaskExecutors fixes LLM-generated executor_id and executor_type
// against the robot's actual resource lists. It handles two common LLM errors:
// 1. Partial executor_id (e.g. "report-writer" instead of "yao.report-writer")
// 2. Wrong executor_type (e.g. classifying an assistant as "mcp")
func NormalizeTaskExecutors(tasks []robottypes.Task, robot *robottypes.Robot) {
if robot == nil || robot.Config == nil || robot.Config.Resources == nil {
return
}
agentSet := make(map[string]bool, len(robot.Config.Resources.Agents))
for _, id := range robot.Config.Resources.Agents {
agentSet[id] = true
}
mcpSet := make(map[string]bool, len(robot.Config.Resources.MCP))
for _, m := range robot.Config.Resources.MCP {
mcpSet[m.ID] = true
}
for i := range tasks {
task := &tasks[i]
origID := task.ExecutorID
origType := task.ExecutorType
// Skip process tasks — they are not in resource lists
if task.ExecutorType == robottypes.ExecutorProcess {
continue
}
// Step 1: Try exact match first
if agentSet[task.ExecutorID] {
task.ExecutorType = robottypes.ExecutorAssistant
if origType != task.ExecutorType {
kunlog.Trace("[normalize] task %s: executor_type %s -> %s (crosscheck)", task.ID, origType, task.ExecutorType)
}
continue
}
if mcpSet[task.ExecutorID] {
task.ExecutorType = robottypes.ExecutorMCP
if origType != task.ExecutorType {
kunlog.Trace("[normalize] task %s: executor_type %s -> %s (crosscheck)", task.ID, origType, task.ExecutorType)
}
continue
}
// Step 2: Suffix match — LLM may omit namespace prefix
if match := suffixMatch(task.ExecutorID, robot.Config.Resources.Agents); match != "" {
task.ExecutorID = match
task.ExecutorType = robottypes.ExecutorAssistant
kunlog.Trace("[normalize] task %s: executor_id %s -> %s (suffix match)", task.ID, origID, match)
continue
}
mcpIDs := make([]string, 0, len(robot.Config.Resources.MCP))
for _, m := range robot.Config.Resources.MCP {
mcpIDs = append(mcpIDs, m.ID)
}
if match := suffixMatch(task.ExecutorID, mcpIDs); match != "" {
task.ExecutorID = match
task.ExecutorType = robottypes.ExecutorMCP
kunlog.Trace("[normalize] task %s: executor_id %s -> %s (suffix match)", task.ID, origID, match)
continue
}
}
}
// suffixMatch finds the first entry in candidates that ends with "."+partial
// (or equals partial exactly, which is already handled by the caller).
func suffixMatch(partial string, candidates []string) string {
suffix := "." + partial
for _, c := range candidates {
if strings.HasSuffix(c, suffix) {
return c
}
}
return ""
}

View file

@ -433,7 +433,6 @@ func (v *Validator) validateSemantic(task *robottypes.Task, output interface{})
// Call validation agent
caller := NewAgentCaller()
caller.Connector = v.robot.LanguageModel
caller.Workspace = v.robot.Workspace
result, err := caller.CallWithMessages(v.ctx, validationAgentID, validationPrompt)
if err != nil {
@ -641,7 +640,6 @@ func (av *robotAgentValidator) Validate(agentID string, output, input, criteria
// Call agent
caller := NewAgentCaller()
caller.Connector = av.v.robot.LanguageModel
caller.Workspace = av.v.robot.Workspace
callResult, err := caller.CallWithMessages(av.v.ctx, agentID, string(inputJSON))
if err != nil {

View file

@ -0,0 +1,850 @@
package standard
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"path"
"path/filepath"
"regexp"
"strings"
"text/template"
kunlog "github.com/yaoapp/kun/log"
agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/llm"
robottypes "github.com/yaoapp/yao/agent/robot/types"
taiworkspace "github.com/yaoapp/yao/tai/workspace"
"github.com/yaoapp/yao/workspace"
"gopkg.in/yaml.v3"
)
//go:embed prompts/workspace.yml
var workspacePromptYAML []byte
type workspacePrompts struct {
Workspace string `yaml:"workspace"`
Context string `yaml:"context"`
Instructions string `yaml:"instructions"`
}
var wsPromptTpls struct {
Workspace *template.Template
Context *template.Template
Instructions *template.Template
}
func init() {
var p workspacePrompts
if err := yaml.Unmarshal(workspacePromptYAML, &p); err != nil {
return
}
wsPromptTpls.Workspace, _ = template.New("ws").Parse(p.Workspace)
wsPromptTpls.Context, _ = template.New("ctx").Parse(p.Context)
wsPromptTpls.Instructions, _ = template.New("inst").Parse(p.Instructions)
}
// Manifest is the shared context hub for an execution, written as manifest.json.
type Manifest struct {
ExecID string `json:"exec_id"`
RobotID string `json:"robot_id"`
Goals string `json:"goals"`
Tasks []ManifestTask `json:"tasks"`
}
// ManifestTask represents a single task entry in the manifest.
type ManifestTask struct {
ID string `json:"id"`
Order int `json:"order"`
Description string `json:"description"`
Executor string `json:"executor"`
ExecutorType string `json:"executor_type"`
Status string `json:"status"`
Summary string `json:"summary,omitempty"`
KeyOutputs []string `json:"key_outputs,omitempty"`
Files []ManifestFile `json:"files,omitempty"`
Error string `json:"error,omitempty"`
}
// ManifestFile represents a produced artifact.
type ManifestFile struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Desc string `json:"desc,omitempty"`
URI string `json:"uri,omitempty"`
}
// ensureRobotWorkspace guarantees a workspace FS exists for the robot.
// If robot.Workspace is empty, it derives a deterministic ID and auto-creates.
// It also writes back robot.Workspace so subsequent callers get the correct ID.
func ensureRobotWorkspace(ctx *robottypes.Context, robot *robottypes.Robot) (taiworkspace.FS, error) {
wsm := workspace.M()
if wsm == nil {
return nil, fmt.Errorf("workspace manager not available")
}
wsID := robot.Workspace
if wsID == "" {
nodes := wsm.Nodes()
nodeID := ""
for _, n := range nodes {
if n.Online {
nodeID = n.Name
break
}
}
if nodeID == "" {
return nil, fmt.Errorf("no available node for workspace")
}
wsID = workspace.DefaultWorkspaceID(robot.TeamID, nodeID)
if _, err := wsm.Get(ctx, wsID); err != nil {
if _, err := wsm.Create(ctx, workspace.CreateOptions{
ID: wsID,
Name: "Robot Workspace",
Owner: robot.TeamID,
Node: nodeID,
}); err != nil {
return nil, fmt.Errorf("workspace create failed: %w", err)
}
}
robot.Workspace = wsID
}
return wsm.FS(ctx, wsID)
}
// initManifest creates the initial manifest.json with goals and pending task list.
// Uses slice index (not P2's order field) so manifests always have sequential numbering.
func (r *Runner) initManifest(exec *robottypes.Execution) {
if r.wsFS == nil {
return
}
goalsContent := ""
if exec.Goals != nil {
goalsContent = exec.Goals.Content
}
m := &Manifest{
ExecID: exec.ID,
RobotID: r.robot.MemberID,
Goals: goalsContent,
Tasks: make([]ManifestTask, 0, len(exec.Tasks)),
}
for i, t := range exec.Tasks {
m.Tasks = append(m.Tasks, ManifestTask{
ID: t.ID,
Order: i,
Description: t.Description,
Executor: t.ExecutorID,
ExecutorType: string(t.ExecutorType),
Status: string(t.Status),
})
}
r.writeManifest(m)
}
// writeManifest serializes and writes manifest.json.
func (r *Runner) writeManifest(m *Manifest) {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
kunlog.Warn("[robot-workspace] marshal manifest: %v", err)
return
}
p := path.Join(r.execDir, "manifest.json")
if err := r.wsFS.WriteFile(p, data, 0644); err != nil {
kunlog.Warn("[robot-workspace] write manifest: %v", err)
}
}
// readManifest reads and parses manifest.json from workspace.
func (r *Runner) readManifest() (*Manifest, error) {
if r.wsFS == nil {
return nil, fmt.Errorf("wsFS not available")
}
data, err := r.wsFS.ReadFile(path.Join(r.execDir, "manifest.json"))
if err != nil {
return nil, err
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return &m, nil
}
// writeTaskOutput writes the three task files and updates manifest after task completion.
func (r *Runner) writeTaskOutput(task *robottypes.Task, result *robottypes.TaskResult, promptSnapshot string) {
if r.wsFS == nil {
return
}
taskID := task.ID
// Write task-NNN.input.md (prompt snapshot for debug)
if promptSnapshot != "" {
inputPath := path.Join(r.execDir, taskID+".input.md")
if err := r.wsFS.WriteFile(inputPath, []byte(promptSnapshot), 0644); err != nil {
kunlog.Warn("[robot-workspace] write %s.input.md: %v", taskID, err)
}
}
// Write task-NNN.output.md (full output)
outputText := formatOutputAsText(result.Output)
outputPath := path.Join(r.execDir, taskID+".output.md")
if err := r.wsFS.WriteFile(outputPath, []byte(outputText), 0644); err != nil {
kunlog.Warn("[robot-workspace] write %s.output.md: %v", taskID, err)
}
// Write task-NNN.json (metadata)
meta := map[string]interface{}{
"id": taskID,
"executor": task.ExecutorID,
"executor_type": string(task.ExecutorType),
"status": string(task.Status),
"duration_ms": result.Duration,
"success": result.Success,
}
if result.Error != "" {
meta["error"] = result.Error
}
metaJSON, _ := json.MarshalIndent(meta, "", " ")
metaPath := path.Join(r.execDir, taskID+".json")
if err := r.wsFS.WriteFile(metaPath, metaJSON, 0644); err != nil {
kunlog.Warn("[robot-workspace] write %s.json: %v", taskID, err)
}
r.updateManifestForTask(task, result)
}
// updateManifestForTask reads manifest, updates the matching task entry, and writes back.
func (r *Runner) updateManifestForTask(task *robottypes.Task, result *robottypes.TaskResult) {
m, err := r.readManifest()
if err != nil {
kunlog.Warn("[robot-workspace] read manifest for update: %v", err)
return
}
// Scan files first so LLM summary can reference them
files := r.scanTaskArtifacts(task.ID)
outputURIs := r.extractAndVerifyFiles(result.Output)
files = mergeManifestFiles(files, outputURIs)
for i := range m.Tasks {
if m.Tasks[i].ID == task.ID {
if result.Success {
m.Tasks[i].Status = "completed"
summary := r.llmSummarize(task, result.Output, files)
if summary == "" {
summary = generateSummary(result.Output)
}
m.Tasks[i].Summary = summary
m.Tasks[i].KeyOutputs = extractKeyOutputs(result.Output)
} else {
m.Tasks[i].Status = "failed"
m.Tasks[i].Error = result.Error
if result.Error != "" {
m.Tasks[i].Summary = "Failed: " + result.Error
}
}
m.Tasks[i].Files = files
break
}
}
r.writeManifest(m)
}
// scanTaskArtifacts scans the task-id/ directory for produced files.
func (r *Runner) scanTaskArtifacts(taskID string) []ManifestFile {
if r.wsFS == nil {
return nil
}
dirPath := path.Join(r.execDir, taskID)
entries, err := r.wsFS.ReadDir(dirPath)
if err != nil {
return nil
}
var files []ManifestFile
for _, e := range entries {
if e.IsDir() {
continue
}
files = append(files, ManifestFile{
Name: e.Name(),
Type: mimeFromExt(filepath.Ext(e.Name())),
})
}
return files
}
// llmSummarize uses a lightweight LLM to generate a concise task summary
// from the full task context (description, output, produced files).
// Returns empty string on any failure, allowing caller to fall back to static extraction.
func (r *Runner) llmSummarize(task *robottypes.Task, output interface{}, files []ManifestFile) string {
text := flattenOutput(output)
if text == "" {
return ""
}
conn, _, err := llm.ResolveConnector("use::light", r.ctx.Auth)
if err != nil {
kunlog.Warn("[robot-workspace] llmSummarize resolve connector: %v", err)
return ""
}
opts := llm.BuildCompletionOptions(conn, nil)
instance, err := llm.New(conn, opts)
if err != nil {
kunlog.Warn("[robot-workspace] llmSummarize create LLM: %v", err)
return ""
}
var sb strings.Builder
sb.WriteString("Task: " + task.Description + "\n\n")
if len(files) > 0 {
sb.WriteString("Produced files:\n")
for _, f := range files {
sb.WriteString("- " + f.Name + " (" + f.Type + ")\n")
}
sb.WriteString("\n")
}
sb.WriteString("Output:\n")
outputText := text
if len(outputText) > 4000 {
outputText = outputText[:4000]
}
sb.WriteString(outputText)
messages := []agentcontext.Message{
{Role: agentcontext.RoleSystem, Content: "Summarize the task execution result in 1-2 concise sentences. " +
"Focus on what was actually produced or accomplished, not the process. " +
"If files were produced, mention them. Reply in the same language as the task description."},
{Role: agentcontext.RoleUser, Content: sb.String()},
}
agentCtx := agentcontext.New(r.ctx.Context, r.ctx.Auth, "")
defer agentCtx.Release()
resp, err := instance.Post(agentCtx, messages, opts)
if err != nil {
kunlog.Warn("[robot-workspace] llmSummarize Post: %v", err)
return ""
}
return extractLLMContent(resp)
}
// extractLLMContent extracts the text content from a CompletionResponse.
func extractLLMContent(resp *agentcontext.CompletionResponse) string {
if resp == nil {
return ""
}
if s, ok := resp.Content.(string); ok {
return strings.TrimSpace(s)
}
return ""
}
// workspaceURIRegex matches workspace://wsID/path patterns in markdown links and plain text.
// Excludes trailing backticks, quotes, and brackets that are markdown formatting artifacts.
var workspaceURIRegex = regexp.MustCompile("workspace://([^/\\s)]+)/([^\\s)\\]`\"']+)")
// extractAndVerifyFiles extracts workspace:// URIs from output and verifies each
// file exists via wsFS.Stat, eliminating false positives from regex artifacts.
func (r *Runner) extractAndVerifyFiles(output interface{}) []ManifestFile {
text := flattenOutput(output)
if text == "" || r.wsFS == nil {
return nil
}
wsID, err := r.wsFS.GetID()
if err != nil {
return nil
}
prefix := "workspace://" + wsID + "/"
matches := workspaceURIRegex.FindAllStringSubmatch(text, -1)
seen := make(map[string]bool)
var files []ManifestFile
for _, m := range matches {
uri := "workspace://" + m[1] + "/" + m[2]
if seen[uri] {
continue
}
seen[uri] = true
if !strings.HasPrefix(uri, prefix) {
continue
}
relPath := strings.TrimPrefix(uri, prefix)
info, err := r.wsFS.Stat(relPath)
if err != nil || info.IsDir() {
continue
}
name := filepath.Base(relPath)
files = append(files, ManifestFile{
Name: name,
Type: mimeFromExt(filepath.Ext(name)),
URI: uri,
})
}
return files
}
// mergeManifestFiles deduplicates files from scanTaskArtifacts and extractWorkspaceURIs.
// If a URI-bearing entry has the same Name as a scan entry, the URI is merged onto the
// existing entry instead of creating a duplicate.
func mergeManifestFiles(scanned []ManifestFile, fromURIs []ManifestFile) []ManifestFile {
if len(fromURIs) == 0 {
return scanned
}
nameIndex := make(map[string]int, len(scanned))
for i, f := range scanned {
nameIndex[f.Name] = i
}
for _, uf := range fromURIs {
if idx, exists := nameIndex[uf.Name]; exists {
if scanned[idx].URI == "" {
scanned[idx].URI = uf.URI
}
} else {
nameIndex[uf.Name] = len(scanned)
scanned = append(scanned, uf)
}
}
return scanned
}
// --- Summary & key_outputs extraction ---
// llmPrefixPatterns are common LLM filler prefixes that carry no information.
var llmPrefixPatterns = []string{
"It seems ", "It appears ", "Here is ", "Here's ",
"Based on ", "I encountered ", "I wasn't able ",
"I'm currently unable ", "Let me ", "I recommend ",
}
// generateSummary produces a concise summary of the task's actual output/result.
// It prioritizes conclusion/summary sections over the beginning of the output,
// because agent responses typically start with planning/thinking text.
func generateSummary(output interface{}) string {
text := flattenOutput(output)
if text == "" {
return ""
}
const maxLen = 200
// Try to find an explicit summary/conclusion section
for _, heading := range []string{"## Summary", "## Conclusion", "## Result", "## 总结", "## 结论", "## 结果"} {
if idx := strings.Index(text, heading); idx >= 0 {
section := strings.TrimSpace(text[idx+len(heading):])
section = strings.TrimPrefix(section, "\n")
if nextH := strings.Index(section, "\n## "); nextH > 0 {
section = section[:nextH]
}
section = strings.TrimSpace(section)
if section != "" {
if len(section) > maxLen {
return section[:maxLen] + "..."
}
return section
}
}
}
// No explicit section — use last substantive paragraph as it's
// more likely to contain the actual result than the beginning
paragraphs := strings.Split(text, "\n\n")
for i := len(paragraphs) - 1; i >= 0; i-- {
p := strings.TrimSpace(paragraphs[i])
if p == "" || len(p) < 10 {
continue
}
isFiller := false
for _, prefix := range llmPrefixPatterns {
if strings.HasPrefix(p, prefix) {
isFiller = true
break
}
}
if isFiller {
continue
}
if len(p) > maxLen {
return p[:maxLen] + "..."
}
return p
}
// Fallback: skip filler and take from the beginning
text = skipFillerPrefixes(text)
if len(text) > maxLen {
return text[:maxLen] + "..."
}
return text
}
// skipFillerPrefixes skips past common LLM opening phrases to find substantive content.
func skipFillerPrefixes(text string) string {
lines := strings.SplitN(text, "\n", 20)
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
isFiller := false
for _, prefix := range llmPrefixPatterns {
if strings.HasPrefix(trimmed, prefix) {
isFiller = true
break
}
}
if !isFiller {
return strings.Join(lines[i:], "\n")
}
}
return text
}
// boldPatternRegex matches **bold text** in markdown
var boldPatternRegex = regexp.MustCompile(`\*\*([^*]+)\*\*`)
// extractKeyOutputs tries to extract structured key_outputs from the output.
func extractKeyOutputs(output interface{}) []string {
if output == nil {
return nil
}
// If output is a map with key_outputs/outputs/results, extract directly
if m, ok := output.(map[string]interface{}); ok {
for _, key := range []string{"key_outputs", "outputs", "results"} {
if arr, ok := m[key].([]interface{}); ok {
result := make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
result = append(result, s)
}
}
if len(result) > 0 {
return capSlice(result, 5)
}
}
}
}
text := flattenOutput(output)
// Try ## headings
if strings.Contains(text, "\n## ") {
var headings []string
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, "## ") {
headings = append(headings, strings.TrimPrefix(line, "## "))
}
}
if len(headings) > 0 {
return capSlice(headings, 5)
}
}
// Try **bold** items from numbered lists or bullet lists
// e.g. "1. **Model-Driven Architecture:**" or "- **Low-Code Engine**"
var boldItems []string
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.Contains(trimmed, "**") {
continue
}
// Only extract from list-like lines
if !(strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") ||
(len(trimmed) > 2 && trimmed[0] >= '0' && trimmed[0] <= '9' && trimmed[1] == '.')) {
continue
}
matches := boldPatternRegex.FindStringSubmatch(trimmed)
if len(matches) >= 2 {
item := strings.TrimRight(matches[1], ":")
if len(item) > 0 && len(item) < 80 {
boldItems = append(boldItems, item)
}
}
}
if len(boldItems) > 0 {
return capSlice(boldItems, 5)
}
return nil
}
func capSlice(s []string, max int) []string {
if len(s) > max {
return s[:max]
}
return s
}
// flattenOutput converts any output value to a plain text string.
func flattenOutput(output interface{}) string {
if output == nil {
return ""
}
switch v := output.(type) {
case string:
return v
case map[string]interface{}:
if text, ok := v["text"].(string); ok {
return text
}
if content, ok := v["content"].(string); ok {
return content
}
b, _ := json.Marshal(v)
return string(b)
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}
// formatOutputAsText converts task output to markdown text for .output.md files.
func formatOutputAsText(output interface{}) string {
if output == nil {
return ""
}
switch v := output.(type) {
case string:
return v
default:
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}
// --- Prompt template rendering ---
// wsTemplateData holds template variables for the workspace section.
type wsTemplateData struct {
ExecDir string
Files []wsFileEntry
TaskDir string
}
type wsFileEntry struct {
Path string
Desc string
}
// ctxTemplateData holds template variables for the execution context section.
type ctxTemplateData struct {
Goals string
Locale string
CompletedTasks []ctxTaskEntry
FailedTasks []ctxFailedEntry
CurrentOrder int
CurrentID string
CurrentDesc string
CurrentType string
CurrentExec string
FailureWarning string
}
type ctxTaskEntry struct {
Seq int
ID string
Description string
ExecutorType string
Executor string
Summary string
KeyOutputs string
Files string
HasFiles bool
}
type ctxFailedEntry struct {
Seq int
ID string
Description string
Error string
}
// instTemplateData holds template variables for the instructions section.
type instTemplateData struct {
TaskInstructions string
ExpectedOutput string
}
// buildWorkspacePrompt renders the full prompt for a task from manifest + template.
func (r *Runner) buildWorkspacePrompt(manifest *Manifest, taskIndex int, task *robottypes.Task, taskInstructions string) string {
if manifest == nil || taskIndex >= len(manifest.Tasks) {
return taskInstructions
}
var sb strings.Builder
// Section 1: Workspace
if wsPromptTpls.Workspace != nil {
wd := wsTemplateData{
ExecDir: r.execDir + "/",
TaskDir: r.execDir + "/" + manifest.Tasks[taskIndex].ID + "/",
}
wd.Files = append(wd.Files, wsFileEntry{
Path: "manifest.json",
Desc: "Execution context: goals, completed task summaries, progress",
})
for i := 0; i < taskIndex; i++ {
t := manifest.Tasks[i]
if t.Status == "completed" {
wd.Files = append(wd.Files, wsFileEntry{
Path: t.ID + ".output.md",
Desc: "Full output: " + t.Description,
})
for _, f := range t.Files {
desc := "Artifact: " + f.Name
if f.URI != "" {
desc = "Artifact: " + f.Name + " (" + f.URI + ")"
}
filePath := t.ID + "/" + f.Name
if f.URI != "" {
filePath = f.URI
}
wd.Files = append(wd.Files, wsFileEntry{
Path: filePath,
Desc: desc,
})
}
}
}
var buf bytes.Buffer
if err := wsPromptTpls.Workspace.Execute(&buf, wd); err == nil {
sb.WriteString(buf.String())
sb.WriteString("\n\n")
}
}
// Section 2: Execution Context
if wsPromptTpls.Context != nil {
ct := manifest.Tasks[taskIndex]
cd := ctxTemplateData{
Goals: manifest.Goals,
Locale: r.locale,
CurrentID: ct.ID,
CurrentOrder: taskIndex + 1,
CurrentDesc: ct.Description,
CurrentType: ct.ExecutorType,
CurrentExec: ct.Executor,
}
seq := 0
failedCount := 0
for i := 0; i < taskIndex; i++ {
t := manifest.Tasks[i]
seq++
if t.Status == "completed" {
entry := ctxTaskEntry{
Seq: seq,
ID: t.ID,
Description: t.Description,
ExecutorType: t.ExecutorType,
Executor: t.Executor,
Summary: t.Summary,
KeyOutputs: strings.Join(t.KeyOutputs, ", "),
}
if len(t.Files) > 0 {
entry.HasFiles = true
names := make([]string, 0, len(t.Files))
for _, f := range t.Files {
names = append(names, f.Name)
}
entry.Files = strings.Join(names, ", ")
}
cd.CompletedTasks = append(cd.CompletedTasks, entry)
} else if t.Status == "failed" {
failedCount++
errMsg := t.Error
if errMsg == "" {
errMsg = t.Summary
}
cd.FailedTasks = append(cd.FailedTasks, ctxFailedEntry{
Seq: seq,
ID: t.ID,
Description: t.Description,
Error: errMsg,
})
}
}
// P8: failure cascade warning
if failedCount > 0 && len(cd.CompletedTasks) == 0 {
cd.FailureWarning = "WARNING: All previous tasks failed. You may lack necessary input data. Do your best with available information or report the limitation."
} else if failedCount > 0 {
cd.FailureWarning = fmt.Sprintf("Note: %d previous task(s) failed. Some expected input may be missing.", failedCount)
}
var buf bytes.Buffer
if err := wsPromptTpls.Context.Execute(&buf, cd); err == nil {
sb.WriteString(buf.String())
sb.WriteString("\n\n")
}
}
// Section 3: Task Instructions (enriched with expected_output from P2)
if wsPromptTpls.Instructions != nil {
instData := instTemplateData{TaskInstructions: taskInstructions}
if task != nil && task.ExpectedOutput != "" {
instData.ExpectedOutput = task.ExpectedOutput
}
var buf bytes.Buffer
if err := wsPromptTpls.Instructions.Execute(&buf, instData); err == nil {
sb.WriteString(buf.String())
}
} else {
sb.WriteString("## Task Instructions\n\n")
sb.WriteString(taskInstructions)
}
return sb.String()
}
func mimeFromExt(ext string) string {
switch strings.ToLower(ext) {
case ".md":
return "text/markdown"
case ".html", ".htm":
return "text/html"
case ".json":
return "application/json"
case ".pdf":
return "application/pdf"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".csv":
return "text/csv"
case ".txt":
return "text/plain"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
default:
return "application/octet-stream"
}
}

View file

@ -0,0 +1,371 @@
package standard
import (
"context"
"path"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/yao/agent"
agentcontext "github.com/yaoapp/yao/agent/context"
robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/llmprovider"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/setting"
"github.com/yaoapp/yao/tai/volume"
taiworkspace "github.com/yaoapp/yao/tai/workspace"
)
// ============================================================================
// extractLLMContent — pure unit tests (no external dependencies)
// ============================================================================
func TestExtractLLMContent(t *testing.T) {
t.Run("string content", func(t *testing.T) {
resp := &agentcontext.CompletionResponse{Content: " hello world "}
assert.Equal(t, "hello world", extractLLMContent(resp))
})
t.Run("nil response", func(t *testing.T) {
assert.Equal(t, "", extractLLMContent(nil))
})
t.Run("non-string content", func(t *testing.T) {
resp := &agentcontext.CompletionResponse{Content: []interface{}{"a", "b"}}
assert.Equal(t, "", extractLLMContent(resp))
})
t.Run("empty string content", func(t *testing.T) {
resp := &agentcontext.CompletionResponse{Content: " "}
assert.Equal(t, "", extractLLMContent(resp))
})
t.Run("multiline content trimmed", func(t *testing.T) {
resp := &agentcontext.CompletionResponse{Content: "\n summary line\n"}
assert.Equal(t, "summary line", extractLLMContent(resp))
})
t.Run("int content", func(t *testing.T) {
resp := &agentcontext.CompletionResponse{Content: 42}
assert.Equal(t, "", extractLLMContent(resp))
})
}
// ============================================================================
// extractAndVerifyFiles — unit tests with local volume-backed FS
// ============================================================================
func newTestWorkspaceFS(t *testing.T) taiworkspace.FS {
t.Helper()
dir := t.TempDir()
vol := volume.NewLocal(dir)
t.Cleanup(func() { vol.Close() })
wfs := taiworkspace.New(vol, "ws-test")
t.Cleanup(func() { wfs.Close() })
return wfs
}
func TestExtractAndVerifyFiles(t *testing.T) {
t.Run("valid URI with existing file", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
require.NoError(t, wfs.MkdirAll("robots/r1/exec1/task-001", 0755))
require.NoError(t, wfs.WriteFile("robots/r1/exec1/task-001/notes.md", []byte("hello"), 0644))
r := &Runner{
wsFS: wfs,
execDir: "robots/r1/exec1",
}
output := "I wrote the file to workspace://ws-test/robots/r1/exec1/task-001/notes.md for you."
files := r.extractAndVerifyFiles(output)
require.Len(t, files, 1)
assert.Equal(t, "notes.md", files[0].Name)
assert.Equal(t, "text/markdown", files[0].Type)
assert.Equal(t, "workspace://ws-test/robots/r1/exec1/task-001/notes.md", files[0].URI)
})
t.Run("URI with non-existent file filtered out", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
r := &Runner{
wsFS: wfs,
execDir: "robots/r1/exec1",
}
output := "See workspace://ws-test/robots/r1/exec1/task-001/missing.pdf"
files := r.extractAndVerifyFiles(output)
assert.Empty(t, files)
})
t.Run("URI with trailing backtick excluded by regex", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
require.NoError(t, wfs.MkdirAll("robots/r1/exec1/task-001", 0755))
require.NoError(t, wfs.WriteFile("robots/r1/exec1/task-001/data.json", []byte("{}"), 0644))
r := &Runner{
wsFS: wfs,
execDir: "robots/r1/exec1",
}
// Backtick-wrapped URI — the regex excludes the backtick from the captured path
output := "`workspace://ws-test/robots/r1/exec1/task-001/data.json`"
files := r.extractAndVerifyFiles(output)
require.Len(t, files, 1)
assert.Equal(t, "data.json", files[0].Name)
assert.Equal(t, "application/json", files[0].Type)
})
t.Run("duplicate URIs deduplicated", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
require.NoError(t, wfs.MkdirAll("robots/r1/exec1/task-001", 0755))
require.NoError(t, wfs.WriteFile("robots/r1/exec1/task-001/report.html", []byte("<h1>hi</h1>"), 0644))
r := &Runner{
wsFS: wfs,
execDir: "robots/r1/exec1",
}
output := "workspace://ws-test/robots/r1/exec1/task-001/report.html and again workspace://ws-test/robots/r1/exec1/task-001/report.html"
files := r.extractAndVerifyFiles(output)
require.Len(t, files, 1)
assert.Equal(t, "report.html", files[0].Name)
})
t.Run("empty output returns nil", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
r := &Runner{wsFS: wfs, execDir: "robots/r1/exec1"}
assert.Nil(t, r.extractAndVerifyFiles(""))
})
t.Run("nil wsFS returns nil", func(t *testing.T) {
r := &Runner{wsFS: nil, execDir: "robots/r1/exec1"}
assert.Nil(t, r.extractAndVerifyFiles("some text with workspace://ws-test/foo/bar"))
})
t.Run("URI from different workspace filtered", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
r := &Runner{wsFS: wfs, execDir: "robots/r1/exec1"}
output := "See workspace://other-ws/robots/r1/exec1/task-001/file.txt"
files := r.extractAndVerifyFiles(output)
assert.Empty(t, files)
})
t.Run("directory URI filtered out", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
require.NoError(t, wfs.MkdirAll("robots/r1/exec1/task-001", 0755))
r := &Runner{wsFS: wfs, execDir: "robots/r1/exec1"}
output := "workspace://ws-test/robots/r1/exec1/task-001"
files := r.extractAndVerifyFiles(output)
assert.Empty(t, files)
})
t.Run("multiple valid files extracted", func(t *testing.T) {
wfs := newTestWorkspaceFS(t)
require.NoError(t, wfs.MkdirAll("robots/r1/exec1/task-002", 0755))
require.NoError(t, wfs.WriteFile("robots/r1/exec1/task-002/slides.html", []byte("<html>"), 0644))
require.NoError(t, wfs.WriteFile("robots/r1/exec1/task-002/slides.pdf", []byte("%PDF"), 0644))
r := &Runner{wsFS: wfs, execDir: "robots/r1/exec1"}
output := "Generated workspace://ws-test/robots/r1/exec1/task-002/slides.html and exported workspace://ws-test/robots/r1/exec1/task-002/slides.pdf"
files := r.extractAndVerifyFiles(output)
require.Len(t, files, 2)
names := []string{files[0].Name, files[1].Name}
assert.Contains(t, names, "slides.html")
assert.Contains(t, names, "slides.pdf")
})
}
// ============================================================================
// mergeManifestFiles — pure unit tests
// ============================================================================
func TestMergeManifestFiles(t *testing.T) {
t.Run("empty fromURIs returns scanned unchanged", func(t *testing.T) {
scanned := []ManifestFile{{Name: "a.md", Type: "text/markdown"}}
result := mergeManifestFiles(scanned, nil)
assert.Equal(t, scanned, result)
})
t.Run("merge URI onto matching scanned entry", func(t *testing.T) {
scanned := []ManifestFile{{Name: "notes.md", Type: "text/markdown"}}
fromURIs := []ManifestFile{{Name: "notes.md", Type: "text/markdown", URI: "workspace://ws/path/notes.md"}}
result := mergeManifestFiles(scanned, fromURIs)
require.Len(t, result, 1)
assert.Equal(t, "workspace://ws/path/notes.md", result[0].URI)
})
t.Run("add new URI entry when no scan match", func(t *testing.T) {
scanned := []ManifestFile{{Name: "a.md", Type: "text/markdown"}}
fromURIs := []ManifestFile{{Name: "b.pdf", Type: "application/pdf", URI: "workspace://ws/b.pdf"}}
result := mergeManifestFiles(scanned, fromURIs)
require.Len(t, result, 2)
assert.Equal(t, "b.pdf", result[1].Name)
})
}
// ============================================================================
// llmSummarize — integration test (real LLM call)
// ============================================================================
func setupLLMProvider(t *testing.T) {
t.Helper()
if err := setting.Init(); err != nil {
t.Skipf("setting.Init failed (store not available): %v", err)
}
if err := llmprovider.Init(); err != nil {
t.Skipf("llmprovider.Init failed: %v", err)
}
if err := agent.SyncLLMDefaults(); err != nil {
t.Skipf("SyncLLMDefaults failed: %v", err)
}
connIDs := connector.AIConnectors
if len(connIDs) == 0 {
t.Skip("no AI connectors available in test env")
}
t.Cleanup(func() {
s, _ := store.Get("__yao.store")
if s != nil {
s.Del("llmprovider:*")
}
c, _ := store.Get("__yao.cache")
if c != nil {
c.Del("llmprovider:*")
}
})
}
func TestLLMSummarize(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test (requires real LLM)")
}
testutils.Prepare(t)
defer testutils.Clean(t)
setupLLMProvider(t)
auth := &oauthtypes.AuthorizedInfo{UserID: "test-user", TeamID: "test-team"}
ctx := robottypes.NewContext(context.Background(), auth)
r := &Runner{
ctx: ctx,
}
task := &robottypes.Task{
ID: "task-001",
Description: "Research Yao Agents platform and compile structured notes",
}
output := `## Research Findings
Yao Agents is an AI-powered platform that enables developers to build intelligent agents.
### Key Features
- **Model-Driven Architecture**: Define data models in YAML/JSON
- **Low-Code Engine**: Visual workflow builder
- **Multi-Agent Orchestration**: Coordinate multiple AI agents
### Conclusion
Yao Agents provides a comprehensive toolkit for building production-ready AI applications with minimal boilerplate code.`
files := []ManifestFile{
{Name: "research-notes.md", Type: "text/markdown"},
}
summary := r.llmSummarize(task, output, files)
t.Logf("LLM Summary: %s", summary)
assert.NotEmpty(t, summary, "summary should not be empty")
assert.Less(t, len(summary), 500, "summary should be concise (< 500 chars)")
}
// ============================================================================
// updateManifestForTask end-to-end — integration test
// ============================================================================
func TestUpdateManifestForTaskWithLLMSummary(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test (requires real LLM)")
}
testutils.Prepare(t)
defer testutils.Clean(t)
setupLLMProvider(t)
wfs := newTestWorkspaceFS(t)
auth := &oauthtypes.AuthorizedInfo{UserID: "test-user", TeamID: "test-team"}
ctx := robottypes.NewContext(context.Background(), auth)
execDir := "robots/r1/exec1"
r := &Runner{
ctx: ctx,
wsFS: wfs,
execDir: execDir,
robot: &robottypes.Robot{MemberID: "r1"},
}
task := robottypes.Task{
ID: "task-001",
Description: "Research Yao Agents platform",
ExecutorID: "yao.general",
ExecutorType: robottypes.ExecutorAssistant,
Status: robottypes.TaskPending,
}
exec := &robottypes.Execution{
ID: "exec1",
Tasks: []robottypes.Task{task},
}
r.initManifest(exec)
// Verify manifest was created with pending status
m, err := r.readManifest()
require.NoError(t, err)
require.Len(t, m.Tasks, 1)
assert.Equal(t, "pending", m.Tasks[0].Status)
// Write an artifact file that scanTaskArtifacts can discover
require.NoError(t, wfs.MkdirAll(path.Join(execDir, "task-001"), 0755))
require.NoError(t, wfs.WriteFile(path.Join(execDir, "task-001", "notes.md"), []byte("research content"), 0644))
// Simulate task completion with output referencing the artifact
wsID, _ := wfs.GetID()
result := &robottypes.TaskResult{
Success: true,
Duration: 5000,
Output: "Completed research. Notes saved to workspace://" + wsID + "/" + execDir + "/task-001/notes.md",
}
r.updateManifestForTask(&task, result)
// Re-read and verify
m, err = r.readManifest()
require.NoError(t, err)
require.Len(t, m.Tasks, 1)
mt := m.Tasks[0]
assert.Equal(t, "completed", mt.Status)
assert.NotEmpty(t, mt.Summary, "summary should be generated (LLM or fallback)")
t.Logf("Summary: %s", mt.Summary)
// Files should include the scanned artifact, potentially with URI merged
assert.NotEmpty(t, mt.Files, "files should be populated")
found := false
for _, f := range mt.Files {
if f.Name == "notes.md" {
found = true
assert.Equal(t, "text/markdown", f.Type)
}
}
assert.True(t, found, "notes.md should be in files list")
}

View file

@ -337,7 +337,6 @@ func (m *Manager) callHostAgent(ctx *types.Context, agentID string, input *types
}
caller := standard.NewConversationCaller(chatID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, string(inputJSON))
if err != nil {
@ -740,7 +739,6 @@ func (m *Manager) callHostAgentStream(ctx *types.Context, agentID string, input
}
caller := standard.NewConversationCaller(chatID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessagesStream(ctx, agentID, string(inputJSON), streamFn)
if err != nil {
@ -960,7 +958,6 @@ func (m *Manager) callHostAgentStreamRaw(ctx *types.Context, agentID string, inp
}
caller := standard.NewConversationCaller(chatID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessagesStreamRaw(ctx, agentID, string(inputJSON), wrappedOnMessage)
if err != nil {

View file

@ -524,6 +524,7 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
Action: req.Action,
Messages: req.Messages,
UserID: ctx.UserID(),
Locale: req.Locale,
}
// Handle plan.add action - schedule for later

View file

@ -14,6 +14,7 @@ type InterveneRequest struct {
Messages []agentcontext.Message `json:"messages"` // user input (text, images, files)
PlanTime *time.Time `json:"plan_time,omitempty"` // for action=plan
ExecutorMode ExecutorMode `json:"executor_mode,omitempty"` // optional: override robot config
Locale string `json:"locale,omitempty"` // language for UI display (e.g., "en", "zh")
}
// EventRequest - event trigger request

View file

@ -2,6 +2,8 @@ package claude
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
@ -26,6 +28,11 @@ var yaoSessionNS = uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")
var safeNameRe = regexp.MustCompile(`[^a-zA-Z0-9_\-.]`)
func hashUserID(raw string) string {
h := sha256.Sum256([]byte(raw))
return hex.EncodeToString(h[:8]) // 16 hex chars — short yet collision-safe
}
func chatIDToSessionUUID(assistantID, chatID string) string {
return uuid.NewSHA1(yaoSessionNS, []byte(assistantID+":"+chatID)).String()
}
@ -133,6 +140,10 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
}
env["WORKDIR"] = workDir
if req.Locale != "" {
env["CTX_LOCALE"] = req.Locale
}
assistantID := req.AssistantID
if assistantID != "" {
configDir := p.PathJoin(workDir, ".yao", "assistants", assistantID)
@ -234,6 +245,21 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
env["ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES"])
logger.Debug("claude-env: MAX_THINKING_TOKENS=%s", env["MAX_THINKING_TOKENS"])
// Override metadata.user_id with a sanitized value.
// Claude CLI sets metadata.user_id to a JSON object that third-party
// Anthropic-compatible APIs (e.g. DeepSeek) reject because the value
// doesn't match ^[a-zA-Z0-9_-]+$.
if _, ok := env["CLAUDE_CODE_EXTRA_BODY"]; !ok {
uid := "yao-sandbox"
if req.Config != nil && req.Config.Owner != "" {
uid = hashUserID(req.Config.Owner)
} else if assistantID != "" {
uid = hashUserID(assistantID)
}
env["CLAUDE_CODE_EXTRA_BODY"] = fmt.Sprintf(`{"metadata":{"user_id":"%s"}}`, uid)
}
logger.Debug("claude-env: EXTRA_BODY=%s", env["CLAUDE_CODE_EXTRA_BODY"])
return env
}

View file

@ -11,6 +11,7 @@ import (
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/sandbox/v2/shared"
)
// streamParser is an explicit state machine for Claude CLI stream-json output.
@ -77,8 +78,7 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
}
}()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
reader := bufio.NewReaderSize(stdout, 64*1024)
startTime := time.Now()
lineCount := 0
@ -87,9 +87,22 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
log.Trace("[claude-parse] stream started")
for scanner.Scan() {
line := scanner.Text()
if line == "" {
for {
line, skipped, err := shared.ReadJSONLine(reader)
if err == io.EOF {
break
}
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
if skipped {
log.Warn("[claude-parse] skipped oversized JSONL line (>%dMB)", shared.MaxLineSize/1024/1024)
continue
}
if len(line) == 0 {
continue
}
lineCount++
@ -105,11 +118,11 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
}
var msg map[string]any
if err := json.Unmarshal([]byte(line), &msg); err != nil {
if err := json.Unmarshal(line, &msg); err != nil {
if len(line) > 200 {
log.Trace("[claude-parse] JSON unmarshal error: %v (line len=%d, prefix=%q)", err, len(line), line[:200])
log.Trace("[claude-parse] JSON unmarshal error: %v (line len=%d, prefix=%q)", err, len(line), string(line[:200]))
} else {
log.Trace("[claude-parse] JSON unmarshal error: %v (line=%q)", err, line)
log.Trace("[claude-parse] JSON unmarshal error: %v (line=%q)", err, string(line))
}
continue
}
@ -141,16 +154,9 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
}
}
log.Trace("[claude-parse] stream ended: lines=%d elapsed=%v completed=%v scanErr=%v",
lineCount, time.Since(startTime).Round(time.Second), p.completed, scanner.Err())
log.Trace("[claude-parse] stream ended: lines=%d elapsed=%v completed=%v",
lineCount, time.Since(startTime).Round(time.Second), p.completed)
if err := scanner.Err(); err != nil {
log.Trace("[claude-parse] scanner error: %v (ctx.Err=%v)", err, ctx.Err())
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
return nil
}

View file

@ -63,6 +63,10 @@ func (s *session) runStream(handler message.StreamFunc) (completed bool, err err
s.logger.Debug("runStream: parse returned completed=%v parseErr=%v", parser.completed, parseErr)
if !parser.completed && parseErr != nil {
s.exec.Cancel()
}
if parser.completed {
s.logger.Info("claude stream completed normally")
return true, nil

View file

@ -329,8 +329,9 @@ func resolveOwnerID(ctx *agentContext.Context) string {
}
// pickNodeByFilter selects a random online node that satisfies the given filter
// and image requirement. If image is non-empty, candidate nodes must have a
// container runtime (Docker or K8s).
// and image requirement. If image is non-empty, nodes with a container runtime
// (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) {
reg := registry.Global()
if reg == nil {
@ -339,6 +340,7 @@ func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error
nodes := reg.List()
var candidates []string
var hostExecFallback []string
for _, n := range nodes {
if n.Status != "online" && n.Status != "" {
continue
@ -372,12 +374,20 @@ func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error
}
if image != "" && !(n.Capabilities.Docker || n.Capabilities.K8s) {
if n.Capabilities.HostExec {
hostExecFallback = append(hostExecFallback, n.TaiID)
}
continue
}
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 {
kind := ""
os := ""

View file

@ -11,6 +11,7 @@ import (
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/sandbox/v2/shared"
)
// streamParser handles OpenCode's JSONL output (--format json).
@ -63,8 +64,7 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
}
}()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
reader := bufio.NewReaderSize(stdout, 64*1024)
startTime := time.Now()
lineCount := 0
@ -72,9 +72,22 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
log.Trace("[opencode-parse] stream started")
for scanner.Scan() {
line := scanner.Text()
if line == "" {
for {
line, skipped, err := shared.ReadJSONLine(reader)
if err == io.EOF {
break
}
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
if skipped {
log.Warn("[opencode-parse] skipped oversized JSONL line (>%dMB)", shared.MaxLineSize/1024/1024)
continue
}
if len(line) == 0 {
continue
}
lineCount++
@ -86,11 +99,11 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
}
var msg map[string]any
if err := json.Unmarshal([]byte(line), &msg); err != nil {
if err := json.Unmarshal(line, &msg); err != nil {
if len(line) > 200 {
log.Trace("[opencode-parse] JSON unmarshal error: %v (line len=%d)", err, len(line))
} else {
log.Trace("[opencode-parse] JSON unmarshal error: %v (line=%q)", err, line)
log.Trace("[opencode-parse] JSON unmarshal error: %v (line=%q)", err, string(line))
}
continue
}
@ -131,15 +144,9 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
}
}
log.Trace("[opencode-parse] stream ended: lines=%d elapsed=%v completed=%v scanErr=%v",
lineCount, time.Since(startTime).Round(time.Second), p.completed, scanner.Err())
log.Trace("[opencode-parse] stream ended: lines=%d elapsed=%v completed=%v",
lineCount, time.Since(startTime).Round(time.Second), p.completed)
if err := scanner.Err(); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
return nil
}

View file

@ -78,6 +78,10 @@ func (s *session) runStream(handler message.StreamFunc) (completed bool, err err
s.logger.Debug("runStream: parse returned completed=%v parseErr=%v", parser.completed, parseErr)
if !parser.completed && parseErr != nil {
s.exec.Cancel()
}
if parser.completed {
s.logger.Info("opencode stream completed normally")
return true, nil

View file

@ -0,0 +1,43 @@
package shared
import "bufio"
// MaxLineSize is the safety threshold for a single JSONL line.
// Lines exceeding this limit are drained and discarded to prevent
// unbounded memory growth (e.g. Claude CLI "result" events containing
// full conversation history).
const MaxLineSize = 50 * 1024 * 1024 // 50MB
// ReadJSONLine reads one complete line from a bufio.Reader.
//
// Returns:
// - line: complete line bytes (without \n); nil when skipped
// - skipped: true if line exceeded MaxLineSize and was drained
// - err: io.EOF at end of stream, or other IO error
func ReadJSONLine(r *bufio.Reader) ([]byte, bool, error) {
var buf []byte
for {
chunk, isPrefix, err := r.ReadLine()
if err != nil {
return nil, false, err
}
buf = append(buf, chunk...)
if len(buf) > MaxLineSize {
if !isPrefix {
buf = nil
return nil, true, nil
}
for isPrefix {
_, isPrefix, err = r.ReadLine()
if err != nil {
return nil, true, err
}
}
buf = nil
return nil, true, nil
}
if !isPrefix {
return buf, false, nil
}
}
}

View file

@ -0,0 +1,234 @@
package shared
import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
"testing"
)
func TestReadJSONLine_ShortLine(t *testing.T) {
input := `{"type":"system","text":"hello"}` + "\n"
r := bufio.NewReaderSize(strings.NewReader(input), 64*1024)
line, skipped, err := ReadJSONLine(r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if skipped {
t.Fatal("expected skipped=false")
}
if string(line) != `{"type":"system","text":"hello"}` {
t.Fatalf("unexpected line: %q", line)
}
}
func TestReadJSONLine_EmptyLine(t *testing.T) {
input := "\n" + `{"type":"ok"}` + "\n"
r := bufio.NewReaderSize(strings.NewReader(input), 64*1024)
line, skipped, err := ReadJSONLine(r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if skipped {
t.Fatal("expected skipped=false")
}
if len(line) != 0 {
t.Fatalf("expected empty line, got %d bytes", len(line))
}
line, skipped, err = ReadJSONLine(r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if skipped {
t.Fatal("expected skipped=false for second line")
}
if string(line) != `{"type":"ok"}` {
t.Fatalf("unexpected second line: %q", line)
}
}
func TestReadJSONLine_MediumLine(t *testing.T) {
payload := strings.Repeat("x", 200*1024) // 200KB — exceeds 64KB buffer, requires multiple ReadLine chunks
input := payload + "\n"
r := bufio.NewReaderSize(strings.NewReader(input), 64*1024)
line, skipped, err := ReadJSONLine(r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if skipped {
t.Fatal("expected skipped=false for 200KB line")
}
if len(line) != 200*1024 {
t.Fatalf("expected 200KB, got %d bytes", len(line))
}
}
func TestReadJSONLine_OversizedLine(t *testing.T) {
saved := MaxLineSize
defer func() {
// MaxLineSize is a const; we test by embedding a smaller threshold.
// Since we can't reassign a const, we build a line that exceeds 50MB.
// Instead, we use a custom helper to keep the test fast.
_ = saved
}()
// Build a line just over MaxLineSize. To keep allocations reasonable
// in tests, we use a custom reader that streams repeated bytes.
totalSize := MaxLineSize + 1024 // slightly over 50MB
src := &repeatingReader{char: 'A', remaining: totalSize}
// Append a newline + a short "next" line so we can verify recovery.
combined := io.MultiReader(src, strings.NewReader("\n{\"type\":\"ok\"}\n"))
r := bufio.NewReaderSize(combined, 64*1024)
line, skipped, err := ReadJSONLine(r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !skipped {
t.Fatal("expected skipped=true for oversized line")
}
if line != nil {
t.Fatalf("expected nil line when skipped, got %d bytes", len(line))
}
// Next line should be readable normally
line, skipped, err = ReadJSONLine(r)
if err != nil {
t.Fatalf("unexpected error reading next line: %v", err)
}
if skipped {
t.Fatal("expected skipped=false for recovery line")
}
if string(line) != `{"type":"ok"}` {
t.Fatalf("unexpected recovery line: %q", line)
}
}
func TestReadJSONLine_EOF(t *testing.T) {
r := bufio.NewReaderSize(strings.NewReader(""), 64*1024)
_, _, err := ReadJSONLine(r)
if err != io.EOF {
t.Fatalf("expected io.EOF, got %v", err)
}
}
func TestReadJSONLine_EOFDuringDrain(t *testing.T) {
// Oversized line without trailing newline — EOF during drain
totalSize := MaxLineSize + 1024
src := &repeatingReader{char: 'B', remaining: totalSize}
r := bufio.NewReaderSize(src, 64*1024)
_, skipped, err := ReadJSONLine(r)
// During drain, ReadLine will eventually hit EOF
if err == nil && !skipped {
t.Fatal("expected either error or skip")
}
if err != nil && err != io.EOF {
t.Fatalf("expected io.EOF during drain, got %v", err)
}
}
func TestReadJSONLine_MultiLineMixed(t *testing.T) {
// Line 1: normal short
// Line 2: oversized
// Line 3: normal short (verify recovery)
var buf bytes.Buffer
buf.WriteString(`{"type":"start"}`)
buf.WriteByte('\n')
oversized := strings.Repeat("Z", MaxLineSize+100)
buf.WriteString(oversized)
buf.WriteByte('\n')
buf.WriteString(`{"type":"end"}`)
buf.WriteByte('\n')
r := bufio.NewReaderSize(&buf, 64*1024)
// Line 1
line, skipped, err := ReadJSONLine(r)
if err != nil {
t.Fatalf("line 1 error: %v", err)
}
if skipped {
t.Fatal("line 1 should not be skipped")
}
if string(line) != `{"type":"start"}` {
t.Fatalf("line 1 unexpected: %q", string(line))
}
// Line 2 (oversized)
line, skipped, err = ReadJSONLine(r)
if err != nil {
t.Fatalf("line 2 error: %v", err)
}
if !skipped {
t.Fatal("line 2 should be skipped")
}
if line != nil {
t.Fatal("line 2 should return nil")
}
// Line 3 (recovery)
line, skipped, err = ReadJSONLine(r)
if err != nil {
t.Fatalf("line 3 error: %v", err)
}
if skipped {
t.Fatal("line 3 should not be skipped")
}
if string(line) != `{"type":"end"}` {
t.Fatalf("line 3 unexpected: %q", string(line))
}
}
func TestReadJSONLine_IOError(t *testing.T) {
expectedErr := fmt.Errorf("simulated IO failure")
r := bufio.NewReaderSize(&errorReader{err: expectedErr}, 64*1024)
_, _, err := ReadJSONLine(r)
if err == nil {
t.Fatal("expected error")
}
if err.Error() != expectedErr.Error() {
t.Fatalf("expected %q, got %q", expectedErr, err)
}
}
// repeatingReader streams a single character `remaining` times without a newline,
// then returns io.EOF. Used to test oversized lines without allocating huge buffers.
type repeatingReader struct {
char byte
remaining int
}
func (r *repeatingReader) Read(p []byte) (int, error) {
if r.remaining <= 0 {
return 0, io.EOF
}
n := len(p)
if n > r.remaining {
n = r.remaining
}
for i := 0; i < n; i++ {
p[i] = r.char
}
r.remaining -= n
return n, nil
}
// errorReader always returns the configured error.
type errorReader struct {
err error
}
func (r *errorReader) Read(p []byte) (int, error) {
return 0, r.err
}

View file

@ -60,4 +60,5 @@ type StreamRequest struct {
Token *SandboxToken // current user's sandbox token for MCP callbacks
Logger *agentContext.RequestLogger // request-scoped logger propagated from agent context
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
}

File diff suppressed because one or more lines are too long

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
goumcp "github.com/yaoapp/gou/mcp"
@ -28,7 +29,7 @@ func authProviderFromCtx(ctx context.Context) *grpcAuthProvider {
if info == nil {
return nil
}
return &grpcAuthProvider{m: map[string]interface{}{
m := map[string]interface{}{
"sub": info.Subject,
"client_id": info.ClientID,
"scope": info.Scope,
@ -36,7 +37,19 @@ func authProviderFromCtx(ctx context.Context) *grpcAuthProvider {
"user_id": info.UserID,
"team_id": info.TeamID,
"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.

View file

@ -2,7 +2,9 @@ package sandbox
import (
"context"
"fmt"
"io"
"strings"
"sync/atomic"
"time"
@ -121,11 +123,33 @@ func (b *Box) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*Ex
return nil, err
}
handle, err := res.Runtime.ExecStream(ctx, b.containerID, cmd, tairuntime.ExecOptions{
execOpts := tairuntime.ExecOptions{
WorkDir: cfg.WorkDir,
Env: cfg.Env,
User: "sandbox",
})
}
handle, err := res.Runtime.ExecStream(ctx, b.containerID, cmd, execOpts)
if err != nil && strings.Contains(err.Error(), "500 Internal Server Error") {
select {
case <-ctx.Done():
return nil, fmt.Errorf("exec create failed: %w (ctx cancelled, no retry)", err)
case <-time.After(2 * time.Second):
}
status := b.inspectStatus(ctx)
if status != "running" {
return nil, fmt.Errorf("exec create failed: %w (container status: %s)", err, status)
}
var retryErr error
handle, retryErr = res.Runtime.ExecStream(ctx, b.containerID, cmd, execOpts)
if retryErr != nil {
return nil, fmt.Errorf("exec create failed after retry: %w (original: %v)", retryErr, err)
}
err = nil
}
if err != nil {
return nil, err
}

View file

@ -7,6 +7,7 @@ import (
"io"
"strconv"
"strings"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
@ -185,6 +186,8 @@ func (d *dockerCore) execStream(ctx context.Context, id string, cmd []string, op
Stdout: stdoutR,
Stderr: stderrR,
Wait: func() (int, error) {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
inspect, err := d.cli.ContainerExecInspect(execCtx, execResp.ID)
if err != nil {
@ -193,6 +196,11 @@ func (d *dockerCore) execStream(ctx context.Context, id string, cmd []string, op
if !inspect.Running {
return inspect.ExitCode, nil
}
select {
case <-execCtx.Done():
return -1, execCtx.Err()
case <-ticker.C:
}
}
},
Cancel: func() {

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"]
}

View file

@ -21,6 +21,7 @@ func GenerateHandler(proc *process.Process) interface{} {
provider := proc.ArgsString(1)
size := proc.ArgsString(2, "1024x1024")
model := proc.ArgsString(3)
authInfo := authorized.ProcessAuthInfo(proc)
if authInfo == nil {
@ -41,6 +42,9 @@ func GenerateHandler(proc *process.Process) interface{} {
}
options := map[string]interface{}{"size": size}
if model != "" {
options["model"] = model
}
resp, err := agentLLM.GenerateImage(conn, prompt, options)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("image generation failed: %v", err)}

View file

@ -17,9 +17,13 @@
"type": "string",
"description": "Image dimensions (default: 1024x1024). Common values: 1024x1024, 1024x1792, 1792x1024.",
"default": "1024x1024"
},
"model": {
"type": "string",
"description": "Model name to use (e.g. 'doubao-seedream-4-5-251128', 'dall-e-3'). Overrides the provider's default model. If omitted, the provider's default model is used."
}
},
"required": ["prompt"]
},
"x-process-args": ["$args.prompt", "$args.provider", "$args.size"]
"x-process-args": ["$args.prompt", "$args.provider", "$args.size", "$args.model"]
}

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

@ -76,8 +76,13 @@ You have access to Yao system tools via the `tai` command in bash.
| `doc_list` | yao-doc | Search/list available process documentation |
| `doc_inspect` | yao-doc | Get detailed docs for a specific process |
| `doc_validate` | yao-doc | Validate a process name and get suggestions |
| `image_read` | yao-image | Read and analyze images using a vision model |
| `image_generate` | yao-image | Generate images from text prompts |
| `image_providers` | yao-image | List available image generation or vision providers |
| `image_read` | yao-image | Read and analyze images using a vision model |
| `image_generate` | yao-image | Generate images from text prompts |
| `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-doc/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 {
@ -43,6 +44,7 @@ func TestSkillsFS_FrontmatterFields(t *testing.T) {
{"skills/yao-process/SKILL.md", "yao-process"},
{"skills/yao-doc/SKILL.md", "yao-doc"},
{"skills/yao-image/SKILL.md", "yao-image"},
{"skills/yao-agent/SKILL.md", "yao-agent"},
}
for _, s := range skills {

View file

@ -8,6 +8,7 @@ import (
mcpTypes "github.com/yaoapp/gou/mcp/types"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/tools/agent"
"github.com/yaoapp/yao/tools/docs"
"github.com/yaoapp/yao/tools/image"
"github.com/yaoapp/yao/tools/proc"
@ -27,18 +28,26 @@ var mcpDocDSL []byte
//go:embed mcps/image.json
var mcpImageDSL []byte
//go:embed mcps/agent.json
var mcpAgentDSL []byte
func init() {
process.RegisterGroup("tools", map[string]process.Handler{
"web_search": websearch.Handler,
"web_fetch": webfetch.Handler,
"process_call": proc.Handler,
"process_allowed": proc.AllowedHandler,
"doc_list": docs.ListHandler,
"doc_inspect": docs.InspectHandler,
"doc_validate": docs.ValidateHandler,
"image_read": image.ReadHandler,
"image_generate": image.GenerateHandler,
"image_providers": image.ProvidersHandler,
"web_search": websearch.Handler,
"web_fetch": webfetch.Handler,
"process_call": proc.Handler,
"process_allowed": proc.AllowedHandler,
"doc_list": docs.ListHandler,
"doc_inspect": docs.InspectHandler,
"doc_validate": docs.ValidateHandler,
"image_read": image.ReadHandler,
"image_generate": image.GenerateHandler,
"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",
@ -49,6 +58,9 @@ func init() {
docs.ListSchemaJSON, docs.InspectSchemaJSON, docs.ValidateSchemaJSON)
registerMCPServer(mcpImageDSL, "yao-image",
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) {

View file

@ -1,8 +1,8 @@
{
"name": "Robot Prompt Generator",
"description": "Generate system prompts for autonomous robots",
"type": "worker",
"connector": "use::default",
"description": "Generate system prompts and resource configuration for robots",
"type": "robot",
"connector": "use::light",
"uses": { "search": "disabled" },
"options": {}
}

View file

@ -1,91 +1,63 @@
- role: system
content: |
You are an expert at crafting system prompts for autonomous AI robots (agents).
Task:
Given a brief role description, generate a comprehensive system prompt that defines:
1. Identity: Who the robot is
2. Responsibilities: What the robot should do
3. Constraints: Rules and limitations
4. Style: Communication tone and approach
Output:
- Return ONLY the system prompt text
- NO markdown code blocks, NO quotes, NO explanation
- Just the prompt content itself
- Use the SAME LANGUAGE as the user's input
Structure (adapt based on role):
You are an expert AI configuration assistant. Your job is to generate or refine a robot (agent) configuration based on user requirements and the current state provided in context.
## Input Context
You will receive a system message containing the current robot configuration with these fields:
- `display_name`: Robot's display name
- `system_prompt`: Current role/responsibilities description
- `language_model`: Currently selected LLM (format: "Label (value)")
- `agents`: Currently assigned collaborative experts
- `mcp_servers`: Currently assigned tools
- `available_agents`: All selectable experts (format: "Name (id)")
- `available_mcp_servers`: All selectable tools (format: "Name (id)")
- `available_language_models`: All selectable models (format: "Label (value)")
## Output Rules
Return a **pure JSON object** — no markdown code fences, no explanation, no extra text.
Only include fields that you have a recommendation for. All fields are optional:
```
You are [role description].
## Core Responsibilities
- [duty 1]
- [duty 2]
- [duty 3]
## Working Principles
- [principle 1]
- [principle 2]
## Constraints
- [constraint 1]
- [constraint 2]
## Communication Style
- [style guideline]
{
"system_prompt": "...",
"language_model": "...",
"agents": [...],
"mcp_servers": [...]
}
```
Examples:
Input: "Sales Analyst"
Output:
You are a Sales Analyst responsible for analyzing sales data and providing actionable insights.
## Core Responsibilities
- Analyze daily/weekly/monthly sales trends
- Identify top-performing products and regions
- Generate sales forecast reports
- Alert on significant anomalies or opportunities
## Working Principles
- Always base conclusions on data, not assumptions
- Prioritize actionable insights over raw statistics
- Consider seasonal factors and market context
## Constraints
- Only access authorized sales databases
- Do not make pricing or strategy decisions
- Escalate sensitive findings to management
## Communication Style
- Clear, concise, business-focused language
- Use charts and tables when presenting data
- Lead with key findings, details follow
---
Input: "你是工程师"
Output:
你是一名专注于技术问题解决的工程师助手。
## 核心职责
- 分析和诊断技术问题
- 提供解决方案和最佳实践建议
- 编写和审查代码
- 监控系统健康状态
## 工作原则
- 先理解问题根因,再提供解决方案
- 优先考虑稳定性和可维护性
- 遵循团队编码规范和架构标准
## 约束条件
- 仅在授权范围内操作系统
- 重大变更需人工确认
- 不自行决定架构重构
## 沟通风格
- 技术准确,表达简洁
- 提供代码示例时注明语言和版本
- 复杂概念配合示意图说明
### Field Specifications
**system_prompt** (string):
Always return this field. Generate a comprehensive role description including:
- Identity and role
- Core responsibilities (3-5 items)
- Working principles
- Constraints
- Communication style
Adapt structure and depth to the described role.
**language_model** (string):
Only include when the role clearly benefits from a specific model capability (e.g. thinking/reasoning tasks → thinking model, simple tasks → flash model).
Value MUST be the exact ID from `available_language_models` (the part inside parentheses).
**agents** (array of strings):
Recommend collaborative experts that match the robot's responsibilities.
Each value MUST be an exact ID from `available_agents` (the part inside parentheses).
Only include agents that are genuinely relevant to the described role.
**mcp_servers** (array of strings):
Recommend tools that the robot would need for its tasks.
Each value MUST be an exact ID from `available_mcp_servers` (the part inside parentheses).
Only include tools that are genuinely relevant to the described role.
## Critical Constraints
- NEVER invent IDs — only use values from the provided available lists
- If no available option fits a field, omit that field entirely
- Use the SAME LANGUAGE as the user's input for `system_prompt` content
- Output MUST be valid JSON parseable by `JSON.parse()`
- Do NOT wrap output in ```json``` or any markdown formatting