330 lines
9.0 KiB
Go
330 lines
9.0 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"ollie/cmd/olliesrv/internal/backend"
|
|
"ollie/cmd/olliesrv/internal/prompts"
|
|
toolclient "ollie/cmd/olliesrv/internal/toolclient"
|
|
"ollie/toolsrv/protocol"
|
|
)
|
|
|
|
// --- Preamble sections ---
|
|
|
|
// Preamble is an ordered list of named text sections. The final system prompt
|
|
// is produced by joining all non-empty sections with newlines. Sections are
|
|
// identified by name so individual parts can be updated without reparsing.
|
|
type Preamble struct {
|
|
sections []section
|
|
}
|
|
|
|
type section struct {
|
|
name Section
|
|
text string
|
|
}
|
|
|
|
// Set replaces (or appends) the named section.
|
|
func (p *Preamble) Set(name Section, text string) {
|
|
for i := range p.sections {
|
|
if p.sections[i].name == name {
|
|
p.sections[i].text = text
|
|
return
|
|
}
|
|
}
|
|
p.sections = append(p.sections, section{name, text})
|
|
}
|
|
|
|
// Get returns the text of a named section.
|
|
func (p *Preamble) Get(name Section) string {
|
|
for _, s := range p.sections {
|
|
if s.name == name {
|
|
return s.text
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// String joins all non-empty sections with a newline separator.
|
|
func (p *Preamble) String() string {
|
|
var sb strings.Builder
|
|
for _, s := range p.sections {
|
|
if s.text == "" {
|
|
continue
|
|
}
|
|
if sb.Len() > 0 {
|
|
sb.WriteByte('\n')
|
|
}
|
|
sb.WriteString(s.text)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// Section is a named preamble section identifier.
|
|
type Section string
|
|
|
|
const (
|
|
SectionSystem Section = "system"
|
|
SectionEnv Section = "env"
|
|
SectionAgent Section = "agent"
|
|
SectionTools Section = "tools"
|
|
)
|
|
|
|
// --- Environment block ---
|
|
|
|
// EnvironmentBlock renders stable environment context for the preamble.
|
|
// Volatile values such as the current date do not belong in the provider-cached
|
|
// prefix and are supplied separately when needed.
|
|
func EnvironmentBlock(cwd, platform string, isGitRepo bool, sandbox string) string {
|
|
git := "false"
|
|
if isGitRepo {
|
|
git = "true"
|
|
}
|
|
return fmt.Sprintf("# Environment\n\n- Working directory: %s\n- Platform: %s\n- Is git repo: %s\n- Sandbox: %s",
|
|
cwd, platform, git, sandbox)
|
|
}
|
|
|
|
// --- Tool rendering ---
|
|
|
|
// renderTools produces a single combined section: a one-line summary per tool
|
|
// followed immediately by its documentation (if any). This keeps the listing
|
|
// and usage instructions adjacent so models don't lose context between them.
|
|
func RenderTools(infos []protocol.ToolInfo) string {
|
|
infos = append([]protocol.ToolInfo(nil), infos...)
|
|
sort.SliceStable(infos, func(i, j int) bool {
|
|
if infos[i].Name == infos[j].Name {
|
|
return infos[i].Server < infos[j].Server
|
|
}
|
|
return infos[i].Name < infos[j].Name
|
|
})
|
|
var sb strings.Builder
|
|
for _, ti := range infos {
|
|
if ti.Server != "" {
|
|
continue
|
|
}
|
|
if ti.Description == "" {
|
|
continue
|
|
}
|
|
if ti.Prompt != "" {
|
|
// If prompt already starts with a markdown header, skip the generated one.
|
|
if !strings.HasPrefix(ti.Prompt, "#") {
|
|
sb.WriteString("## ")
|
|
sb.WriteString(ti.Name)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
sb.WriteString(ti.Prompt)
|
|
} else {
|
|
sb.WriteString("## ")
|
|
sb.WriteString(ti.Name)
|
|
sb.WriteString("\n\n")
|
|
sb.WriteString(ti.Description)
|
|
}
|
|
sb.WriteString("\n\n")
|
|
}
|
|
if sb.Len() == 0 {
|
|
return ""
|
|
}
|
|
return "# Tools\n\n" + sb.String()
|
|
}
|
|
|
|
// --- Runtime ---
|
|
|
|
// Runtime holds the swappable per-agent configuration. It contains everything
|
|
// that changes on an /agent switch but is stable across turns within the same
|
|
// agent. The agent struct stores a pointer to the active Runtime; switching
|
|
// agents replaces it atomically.
|
|
type Runtime struct {
|
|
Backend backend.Backend
|
|
ToolServer *toolclient.ToolsrvConn
|
|
Preamble *Preamble
|
|
Tools []backend.Tool
|
|
ToolMeta map[string]protocol.ToolInfo
|
|
ToolRevision uint64
|
|
Exec toolExecutor
|
|
GenParams backend.GenerationParams
|
|
CompactionModel string
|
|
UserPrompt string // resolved user prompts, prepended to each turn's input
|
|
}
|
|
|
|
// PreambleString returns the fully rendered system prompt.
|
|
func (rt *Runtime) PreambleString() string {
|
|
if rt.Preamble == nil {
|
|
return ""
|
|
}
|
|
return rt.Preamble.String()
|
|
}
|
|
|
|
// BuildRuntime constructs a Runtime from a pre-configured tool server and
|
|
// optional agent config.
|
|
func BuildRuntime(cfg *AgentConfig, srv *toolclient.ToolsrvConn, cwd string, env []string, systemPrompt, envBlock string) *Runtime {
|
|
var allToolInfos []protocol.ToolInfo
|
|
var allTools []backend.Tool
|
|
|
|
if srv != nil {
|
|
allToolInfos, _ = srv.ListTools()
|
|
allTools = toolInfosToBackend(allToolInfos)
|
|
}
|
|
var toolRevision uint64
|
|
if srv != nil {
|
|
toolRevision = srv.ToolRegistryRevision()
|
|
}
|
|
|
|
var agentPrompt string
|
|
var userPrompt string
|
|
var genParams backend.GenerationParams
|
|
if cfg != nil {
|
|
if resolved, err := resolvePrompt(cfg.Prompt, cwd, env); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve prompt: %v\n", err)
|
|
} else {
|
|
agentPrompt = resolved
|
|
}
|
|
if resolved, err := resolvePrompt(cfg.UserPrompts, cwd, env); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve user prompts: %v\n", err)
|
|
} else {
|
|
userPrompt = resolved
|
|
}
|
|
genParams = cfg.GenerationParams
|
|
}
|
|
genParams.CWD = cwd
|
|
|
|
exec := func(ctx context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error) {
|
|
if srv == nil {
|
|
return "", nil, fmt.Errorf("no tool server available")
|
|
}
|
|
raw, err := srv.CallTool(ctx, name, args)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
text, blocks, isErr := extractToolResult(raw)
|
|
if isErr {
|
|
return "", nil, fmt.Errorf("%s", text)
|
|
}
|
|
return text, blocks, nil
|
|
}
|
|
|
|
toolMeta := make(map[string]protocol.ToolInfo, len(allToolInfos))
|
|
for _, ti := range allToolInfos {
|
|
toolMeta[ti.Name] = ti
|
|
}
|
|
|
|
var compactionModel string
|
|
if cfg != nil {
|
|
compactionModel = cfg.CompactionModel
|
|
}
|
|
|
|
// Build preamble from sections
|
|
preamble := &Preamble{}
|
|
preamble.Set(SectionSystem, systemPrompt)
|
|
preamble.Set(SectionEnv, envBlock)
|
|
preamble.Set(SectionAgent, agentPrompt)
|
|
preamble.Set(SectionTools, RenderTools(allToolInfos))
|
|
|
|
return &Runtime{
|
|
ToolServer: srv,
|
|
Tools: allTools,
|
|
ToolMeta: toolMeta,
|
|
ToolRevision: toolRevision,
|
|
Exec: exec,
|
|
Preamble: preamble,
|
|
GenParams: genParams,
|
|
CompactionModel: compactionModel,
|
|
UserPrompt: userPrompt,
|
|
}
|
|
}
|
|
|
|
// ResolveSystemPrompt resolves the system prompt — either from the agent config
|
|
// override path or the embedded default.
|
|
func ResolveSystemPrompt(cfg *AgentConfig) string {
|
|
if cfg != nil && cfg.SystemPrompt != "" {
|
|
return prompts.ResolveSystemPrompt(cfg.SystemPrompt)
|
|
}
|
|
return prompts.DefaultSystemPrompt
|
|
}
|
|
|
|
func toolInfosToBackend(infos []protocol.ToolInfo) []backend.Tool {
|
|
out := make([]backend.Tool, len(infos))
|
|
for i, t := range infos {
|
|
out[i] = backend.Tool{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
Parameters: injectDispatchFlags(t.InputSchema),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// dispatchFlags are optional parameters available on all tools.
|
|
// They are stripped before execution by the dispatch layer.
|
|
var dispatchFlags = map[string]json.RawMessage{
|
|
"bypass": json.RawMessage(`{"type":"boolean","description":"Run outside the sandbox via bypass broker."}`),
|
|
"timeout": json.RawMessage(`{"type":"integer","description":"Timeout in seconds (default: 30). Use 0 for no timeout."}`),
|
|
"sandbox": json.RawMessage(`{"type":"string","description":"Sandbox profile name (default: default)."}`),
|
|
"background": json.RawMessage(`{"type":"boolean","description":"Run in background. Returns PID immediately; output is injected automatically."}`),
|
|
}
|
|
|
|
// injectDispatchFlags adds bypass/timeout/sandbox/background to a tool's schema
|
|
// if it has a properties object. Does not overwrite existing properties.
|
|
func injectDispatchFlags(schema json.RawMessage) json.RawMessage {
|
|
if len(schema) == 0 {
|
|
return schema
|
|
}
|
|
var obj map[string]json.RawMessage
|
|
if json.Unmarshal(schema, &obj) != nil {
|
|
return schema
|
|
}
|
|
propsRaw, ok := obj["properties"]
|
|
if !ok {
|
|
return schema
|
|
}
|
|
var props map[string]json.RawMessage
|
|
if json.Unmarshal(propsRaw, &props) != nil {
|
|
return schema
|
|
}
|
|
modified := false
|
|
for k, v := range dispatchFlags {
|
|
if _, exists := props[k]; !exists {
|
|
props[k] = v
|
|
modified = true
|
|
}
|
|
}
|
|
if !modified {
|
|
return schema
|
|
}
|
|
propsBytes, _ := json.Marshal(props)
|
|
obj["properties"] = propsBytes
|
|
out, _ := json.Marshal(obj)
|
|
return out
|
|
}
|
|
|
|
// extractToolResult parses the tool server's JSON-RPC response into text
|
|
// and optional content blocks.
|
|
//
|
|
// See protocol.ToolResult for the wire format.
|
|
func extractToolResult(raw json.RawMessage) (text string, contentBlocks []backend.ContentBlock, isError bool) {
|
|
var result protocol.ToolResult
|
|
if err := json.Unmarshal(raw, &result); err != nil {
|
|
return string(raw), nil, false
|
|
}
|
|
var parts []string
|
|
for _, c := range result.Content {
|
|
switch c.Type {
|
|
case "text":
|
|
parts = append(parts, c.Text)
|
|
case "image":
|
|
contentBlocks = append(contentBlocks, backend.ContentBlock{
|
|
Type: "image",
|
|
ImageSource: &backend.ImageSource{
|
|
Type: "base64",
|
|
MediaType: c.MediaType,
|
|
Data: c.Data,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n"), contentBlocks, result.IsError
|
|
}
|