- Added support for reading files from workspace URIs in the delivery process, allowing for more flexible attachment management. - Introduced a new `convertWorkspaceAttachment` function to handle workspace-based file retrieval and integration into messenger attachments. - Updated the `AgentCaller` to include execution mode in the context, improving task execution tracking. - Enhanced the `RunDelivery` method to utilize workspace manifests for delivery input, reducing token usage and improving efficiency. - Implemented locale handling in various request structures to support multi-language capabilities in user interfaces.
850 lines
22 KiB
Go
850 lines
22 KiB
Go
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"
|
|
}
|
|
}
|