263 lines
7.0 KiB
Go
263 lines
7.0 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/elf"
|
|
"somegit.dev/Owlibou/gnoma/internal/router"
|
|
"somegit.dev/Owlibou/gnoma/internal/stream"
|
|
"somegit.dev/Owlibou/gnoma/internal/tool"
|
|
"somegit.dev/Owlibou/gnoma/internal/tool/persist"
|
|
)
|
|
|
|
var paramSchema = json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"prompt": {
|
|
"type": "string",
|
|
"description": "The task prompt for the sub-agent (elf)"
|
|
},
|
|
"task_type": {
|
|
"type": "string",
|
|
"description": "Task type hint for provider routing",
|
|
"enum": ["generation", "review", "refactor", "debug", "explain", "planning"]
|
|
},
|
|
"max_turns": {
|
|
"type": "integer",
|
|
"description": "Maximum tool-calling rounds for the elf (0 or omit = unlimited)"
|
|
}
|
|
},
|
|
"required": ["prompt"]
|
|
}`)
|
|
|
|
// Tool allows the LLM to spawn sub-agents (elfs).
|
|
type Tool struct {
|
|
manager *elf.Manager
|
|
ProgressCh chan<- elf.Progress // optional: sends structured progress to TUI
|
|
store *persist.Store
|
|
}
|
|
|
|
func New(mgr *elf.Manager, store *persist.Store) *Tool {
|
|
return &Tool{manager: mgr, store: store}
|
|
}
|
|
|
|
// SetProgressCh sets the channel for forwarding elf progress to the TUI.
|
|
func (t *Tool) SetProgressCh(ch chan<- elf.Progress) {
|
|
t.ProgressCh = ch
|
|
}
|
|
|
|
func (t *Tool) Name() string { return "agent" }
|
|
func (t *Tool) Description() string { return "Spawn a sub-agent (elf) to handle a task independently. The elf gets its own conversation and tools. IMPORTANT: To spawn multiple elfs in parallel, call this tool multiple times in the SAME response — do not wait for one to finish before spawning the next." }
|
|
func (t *Tool) Parameters() json.RawMessage { return paramSchema }
|
|
func (t *Tool) IsReadOnly() bool { return true }
|
|
func (t *Tool) IsDestructive() bool { return false }
|
|
|
|
type agentArgs struct {
|
|
Prompt string `json:"prompt"`
|
|
TaskType string `json:"task_type,omitempty"`
|
|
MaxTurns int `json:"max_turns,omitempty"`
|
|
}
|
|
|
|
func (t *Tool) Execute(ctx context.Context, args json.RawMessage) (tool.Result, error) {
|
|
var a agentArgs
|
|
if err := json.Unmarshal(args, &a); err != nil {
|
|
return tool.Result{}, fmt.Errorf("agent: invalid args: %w", err)
|
|
}
|
|
if a.Prompt == "" {
|
|
return tool.Result{}, fmt.Errorf("agent: prompt required")
|
|
}
|
|
|
|
taskType := parseTaskType(a.TaskType, a.Prompt)
|
|
maxTurns := a.MaxTurns
|
|
|
|
// Truncate description for tree display
|
|
desc := a.Prompt
|
|
if len(desc) > 60 {
|
|
desc = desc[:60] + "…"
|
|
}
|
|
|
|
systemPrompt := "You are an elf — a focused sub-agent of gnoma. Complete the given task thoroughly and concisely. Use tools as needed."
|
|
|
|
var preSave []persist.ResultFile
|
|
if t.store != nil {
|
|
preSave, _ = t.store.List("")
|
|
}
|
|
_ = preSave // used in Task 4 for ResultFilePaths diff
|
|
|
|
e, err := t.manager.Spawn(ctx, taskType, a.Prompt, systemPrompt, maxTurns)
|
|
if err != nil {
|
|
return tool.Result{Output: fmt.Sprintf("Failed to spawn elf: %v", err)}, nil
|
|
}
|
|
|
|
// Send initial progress
|
|
t.sendProgress(elf.Progress{
|
|
ElfID: e.ID(),
|
|
Description: desc,
|
|
Activity: "starting…",
|
|
})
|
|
|
|
// Drain elf events while waiting, forward progress to TUI
|
|
done := make(chan elf.Result, 1)
|
|
go func() { done <- e.Wait() }()
|
|
|
|
// Forward elf streaming events as structured progress
|
|
go func() {
|
|
toolUses := 0
|
|
tokens := 0
|
|
lastSend := time.Now()
|
|
textChars := 0
|
|
|
|
for evt := range e.Events() {
|
|
if t.ProgressCh == nil {
|
|
continue
|
|
}
|
|
|
|
p := elf.Progress{
|
|
ElfID: e.ID(),
|
|
Description: desc,
|
|
ToolUses: toolUses,
|
|
Tokens: tokens,
|
|
}
|
|
|
|
switch evt.Type {
|
|
case stream.EventTextDelta:
|
|
textChars += len(evt.Text)
|
|
// Throttle text progress to every 500ms
|
|
if time.Since(lastSend) < 500*time.Millisecond {
|
|
continue
|
|
}
|
|
p.Activity = fmt.Sprintf("generating… (%d chars)", textChars)
|
|
case stream.EventToolCallDone:
|
|
name := evt.ToolCallName
|
|
if name == "" {
|
|
name = "tool"
|
|
}
|
|
p.Activity = fmt.Sprintf("⚙ [%s] running…", name)
|
|
case stream.EventToolResult:
|
|
toolUses++
|
|
p.ToolUses = toolUses
|
|
out := evt.ToolOutput
|
|
if len(out) > 60 {
|
|
out = out[:60] + "…"
|
|
}
|
|
out = strings.ReplaceAll(out, "\n", " ")
|
|
p.Activity = fmt.Sprintf("→ %s", out)
|
|
case stream.EventUsage:
|
|
if evt.Usage != nil {
|
|
tokens = int(evt.Usage.TotalTokens())
|
|
p.Tokens = tokens
|
|
}
|
|
p.Activity = "" // no activity change on usage alone
|
|
default:
|
|
continue
|
|
}
|
|
|
|
lastSend = time.Now()
|
|
t.sendProgress(p)
|
|
}
|
|
}()
|
|
|
|
var result elf.Result
|
|
select {
|
|
case result = <-done:
|
|
case <-ctx.Done():
|
|
e.Cancel()
|
|
t.sendProgress(elf.Progress{ElfID: e.ID(), Description: desc, Done: true, Error: "cancelled"})
|
|
return tool.Result{Output: "Elf cancelled"}, nil
|
|
case <-time.After(5 * time.Minute):
|
|
e.Cancel()
|
|
t.sendProgress(elf.Progress{ElfID: e.ID(), Description: desc, Done: true, Error: "timed out"})
|
|
return tool.Result{Output: "Elf timed out after 5 minutes"}, nil
|
|
}
|
|
|
|
// Report outcome to router for quality feedback
|
|
t.manager.ReportResult(result)
|
|
|
|
// Send done signal — stays in tree until turn completes
|
|
doneProgress := elf.Progress{
|
|
ElfID: result.ID,
|
|
Description: desc,
|
|
Tokens: int(result.Usage.TotalTokens()),
|
|
Done: true,
|
|
Duration: result.Duration,
|
|
}
|
|
if result.Error != nil {
|
|
doneProgress.Error = result.Error.Error()
|
|
}
|
|
t.sendProgress(doneProgress)
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Elf %s completed (%s, %s, %s)\n\n",
|
|
result.ID, result.Status,
|
|
result.Duration.Round(time.Millisecond),
|
|
formatTokens(int(result.Usage.TotalTokens())),
|
|
)
|
|
if result.Error != nil {
|
|
fmt.Fprintf(&b, "Error: %v\n", result.Error)
|
|
}
|
|
if result.Output != "" {
|
|
// Truncate elf output to avoid flooding parent context.
|
|
// The parent LLM gets enough to summarize; full text stays in the elf.
|
|
output := result.Output
|
|
const maxOutputChars = 2000
|
|
if len(output) > maxOutputChars {
|
|
output = output[:maxOutputChars] + fmt.Sprintf("\n\n[truncated — full output was %d chars]", len(result.Output))
|
|
}
|
|
b.WriteString(output)
|
|
}
|
|
|
|
return tool.Result{
|
|
Output: b.String(),
|
|
Metadata: map[string]any{
|
|
"elf_id": result.ID,
|
|
"status": result.Status.String(),
|
|
"duration": result.Duration.String(),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (t *Tool) sendProgress(p elf.Progress) {
|
|
if t.ProgressCh == nil {
|
|
return
|
|
}
|
|
select {
|
|
case t.ProgressCh <- p:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func formatTokens(tokens int) string {
|
|
if tokens >= 1_000_000 {
|
|
return fmt.Sprintf("%.1fM tokens", float64(tokens)/1_000_000)
|
|
}
|
|
if tokens >= 1_000 {
|
|
return fmt.Sprintf("%.1fk tokens", float64(tokens)/1_000)
|
|
}
|
|
return fmt.Sprintf("%d tokens", tokens)
|
|
}
|
|
|
|
// parseTaskType maps explicit task_type hints to router TaskType.
|
|
// When no hint is provided (empty string), auto-classifies from the prompt.
|
|
func parseTaskType(s string, prompt string) router.TaskType {
|
|
switch strings.ToLower(s) {
|
|
case "generation":
|
|
return router.TaskGeneration
|
|
case "review":
|
|
return router.TaskReview
|
|
case "refactor":
|
|
return router.TaskRefactor
|
|
case "debug":
|
|
return router.TaskDebug
|
|
case "explain":
|
|
return router.TaskExplain
|
|
case "planning":
|
|
return router.TaskPlanning
|
|
default:
|
|
return router.ClassifyTask(prompt).Type
|
|
}
|
|
}
|