Compare commits
9 commits
v1.0.0-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb95bb7871 | ||
|
|
da4a803c11 | ||
|
|
639f0c59fc | ||
|
|
a0c4c543f3 | ||
|
|
6af83149ef | ||
|
|
59bf6ddc8a | ||
|
|
421d946971 | ||
|
|
4376ac9dad | ||
|
|
f230f1e90c |
63 changed files with 4225 additions and 616 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
60
agent/robot/executor/standard/prompts/workspace.yml
Normal file
60
agent/robot/executor/standard/prompts/workspace.yml
Normal 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}}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
850
agent/robot/executor/standard/workspace.go
Normal file
850
agent/robot/executor/standard/workspace.go
Normal 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"
|
||||
}
|
||||
}
|
||||
371
agent/robot/executor/standard/workspace_test.go
Normal file
371
agent/robot/executor/standard/workspace_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -140,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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 := ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
43
agent/sandbox/v2/shared/linereader.go
Normal file
43
agent/sandbox/v2/shared/linereader.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
234
agent/sandbox/v2/shared/linereader_test.go
Normal file
234
agent/sandbox/v2/shared/linereader_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
964
data/bindata.go
964
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
129
tools/agent/agent.go
Normal 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
553
tools/agent/agent_test.go
Normal 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
63
tools/agent/connectors.go
Normal 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
|
||||
}
|
||||
10
tools/agent/connectors_schema.json
Normal file
10
tools/agent/connectors_schema.json
Normal 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
73
tools/agent/deploy.go
Normal 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,
|
||||
}
|
||||
}
|
||||
20
tools/agent/deploy_schema.json
Normal file
20
tools/agent/deploy_schema.json
Normal 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
57
tools/agent/download.go
Normal 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,
|
||||
}
|
||||
}
|
||||
16
tools/agent/download_schema.json
Normal file
16
tools/agent/download_schema.json
Normal 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
146
tools/agent/list.go
Normal 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
|
||||
}
|
||||
15
tools/agent/list_schema.json
Normal file
15
tools/agent/list_schema.json
Normal 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
48
tools/agent/reference.go
Normal 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,
|
||||
}
|
||||
}
|
||||
16
tools/agent/reference_schema.json
Normal file
16
tools/agent/reference_schema.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "agent_reference",
|
||||
"description": "Download agent source code from the host into the .references/ directory for read-only study. Any agent across all namespaces can be downloaded. Use this to study existing agent patterns before building your own.",
|
||||
"process": "tools.agent_reference",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Agent ID in dot notation (e.g. 'yao.slides', 'yao.keeper')"
|
||||
}
|
||||
},
|
||||
"required": ["id"]
|
||||
},
|
||||
"x-process-args": ["$args.id"]
|
||||
}
|
||||
12
tools/mcps/agent.json
Normal file
12
tools/mcps/agent.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
83
tools/skills/yao-agent/SKILL.md
Normal file
83
tools/skills/yao-agent/SKILL.md
Normal 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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue