ollie/agent/runtime.go

275 lines
6.9 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"ollie/backend"
"ollie/prompts"
"ollie/toolsrv"
)
// --- 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 string
text string
}
// Set replaces (or appends) the named section.
func (p *Preamble) Set(name, 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 string) 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 names (constants to avoid typos).
const (
SectionSystem = "system"
SectionEnv = "env"
SectionAgent = "agent"
SectionTools = "tools"
SectionToolDocs = "tool_docs"
)
// --- Environment block ---
// EnvironmentBlock renders the environment context section for the preamble.
func EnvironmentBlock(cwd, platform string, isGitRepo bool, sandbox string) string {
date := time.Now().Format("2006-01-02")
git := "false"
if isGitRepo {
git = "true"
}
return fmt.Sprintf("# Environment\n\n- Working directory: %s\n- Platform: %s\n- Current date: %s\n- Is git repo: %s\n- Sandbox: %s",
cwd, platform, date, git, sandbox)
}
// --- Tool rendering ---
func renderToolListing(infos []toolsrv.ToolInfo) string {
s := toolsrv.BuildToolListing(infos)
if s == "" {
return ""
}
return "# Available Tools\n\n" + s
}
func renderToolDocs(infos []toolsrv.ToolInfo) string {
var sb strings.Builder
for _, ti := range infos {
if ti.Prompt != "" && ti.Server == "" {
sb.WriteString(ti.Prompt)
sb.WriteString("\n\n")
if len(ti.InputSchema) > 0 && string(ti.InputSchema) != "{}" {
fmt.Fprintf(&sb, "**Schema**:\n```json\n%s\n```\n\n", ti.InputSchema)
}
}
}
if sb.Len() == 0 {
return ""
}
return "# Tool Documentation\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 *toolsrv.Conn
Preamble *Preamble
Tools []backend.Tool
ToolMeta map[string]toolsrv.ToolInfo
Exec toolExecutor
GenParams backend.GenerationParams
MaxSteps int
// toolRegistryRevision tracks the last-seen tool registry revision.
toolRegistryRevision uint64
CompactionModel string
Messages []string
}
// 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 *toolsrv.Conn, cwd string, env []string, systemPrompt, envBlock string) *Runtime {
var messages []string
var allToolInfos []toolsrv.ToolInfo
var allTools []backend.Tool
if srv != nil && (cfg == nil || cfg.ToolsEnabled()) {
var listErr error
allToolInfos, listErr = srv.ListTools()
if listErr != nil {
messages = append(messages, fmt.Sprintf("list tools: %v", listErr))
}
allTools = toolInfosToBackend(allToolInfos)
}
var agentPrompt string
var genParams backend.GenerationParams
var maxSteps int
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
}
genParams = cfg.GenerationParams
maxSteps = cfg.MaxSteps
if len(cfg.AllowTools) > 0 && srv != nil {
srv.SetAllowTools(cfg.AllowTools)
}
}
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]toolsrv.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, renderToolListing(allToolInfos))
preamble.Set(SectionToolDocs, renderToolDocs(allToolInfos))
return &Runtime{
ToolServer: srv,
Tools: allTools,
ToolMeta: toolMeta,
Exec: exec,
Preamble: preamble,
GenParams: genParams,
MaxSteps: maxSteps,
CompactionModel: compactionModel,
Messages: messages,
}
}
// 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 []toolsrv.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: t.InputSchema,
}
}
return out
}
func extractToolResult(raw json.RawMessage) (text string, contentBlocks []backend.ContentBlock, isError bool) {
var result struct {
IsError bool `json:"isError"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
MediaType string `json:"media_type"`
Data string `json:"data"`
} `json:"content"`
}
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
}