Details and log output for this agent run.
{"path": ".", "recursive": true}
/project/README.md /project/cmd /project/cmd/zoo /project/cmd/zoo/main.go /project/docker /project/docker/Dockerfile /project/docker/group /project/docker/passwd /project/docker/shadow /project/docker/uid1000-sudo /project/go.mod /project/go.sum /project/internal /project/internal/agentrun /project/internal/agentrun/context.go /project/internal/agentrun/docker.go /project/internal/agentrun/docker_integration_test.go /project/internal/agentrun/forgejo.go /project/internal/agentrun/loop.go /project/internal/agentrun/roster.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/run.go /project/internal/agentrun/sandboxgit.go /project/internal/agentrun/sandboxgit_test.go /project/internal/agentrun/seed_test.go /project/internal/agentrun/system.md /project/internal/config /project/internal/config/config.go /project/internal/config/config_test.go /project/internal/forgejo /project/internal/forgejo/client.go /project/internal/forgejo/client_test.go /project/internal/forgejo/event.go /project/internal/forgejo/poll.go /project/internal/forgejo/poll_test.go /project/internal/forgejo/repo_filter.go /project/internal/forgejo/repo_filter_test.go /project/internal/forgejo/watcher.go /project/internal/forgejo/watcher_test.go /project/internal/forgejo/webhook.go /project/internal/forgejo/webhook_test.go /project/internal/livelog /project/internal/livelog/hub.go /project/internal/livelog/hub_test.go /project/internal/llm /project/internal/llm/client.go /project/internal/llm/streamer.go /project/internal/llm/streamer_test.go /project/internal/llm/types.go /project/internal/scheduler /project/internal/scheduler/scheduler.go /project/internal/scheduler/scheduler_test.go /project/internal/store /project/internal/store/schema.sql /project/internal/store/store.go /project/internal/store/store_test.go /project/internal/tools /project/internal/tools/bash.go /project/internal/tools/context.go /project/internal/tools/edit-file.go /project/internal/tools/forgejo.go /project/internal/tools/grep_search.go /project/internal/tools/list_files.go /project/internal/tools/move_file.go /project/internal/tools/read_file.go /project/internal/tools/registry.go /project/internal/tools/remove_file.go /project/internal/tools/shell.go /project/internal/tools/tools_test.go /project/internal/tools/write_file.go /project/internal/web /project/internal/web/templates.go /project/internal/web/web.go /project/internal/web/web_test.go /project/zoo.hcl.example
{"command": "cd /project && git log --oneline -5 && git status"}
f58963a Merge pull request 'Jobs page: follow the live log tail, with a jump-to-bottom button' (#58) from issue-57-greg into main 0b5138d Merge pull request 'tools: drop the PTY from docker exec, keep stdout/stderr separate' (#54) from issue-52-greg into main c91b25a More correct tool schema. f4da8cd Jobs page: follow the live log tail, with a jump-to-bottom button 98298f6 Merge pull request 'Fix excessive vertical space in job view blocks (#53)' (#55) from issue-53-greg into main On branch issue-59-greg nothing to commit, working tree clean
{"end_line": 100, "path": "internal/llm/client.go", "start_line": 1}
{"path":"internal/llm/client.go","content":"package llm\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n)\n\ntype Client struct {\n\tendpoint string\n\ttoken string\n\tmodel string\n\n\thttpClient *http.Client\n}\n\n// NewClient builds a Client from a configured llm block.\nfunc NewClient(cfg config.LLM) *Client {\n\treturn \u0026Client{\n\t\tendpoint: cfg.OpenAI,\n\t\ttoken: cfg.Token,\n\t\tmodel: cfg.Model,\n\t\thttpClient: http.DefaultClient,\n\t}\n}\n\nfunc (c *Client) StreamChatCompletion(ctx context.Context, req *ChatCompletionRequest) (*Streamer, error) {\n\treq2 := *req\n\n\tif req2.Model == \"\" {\n\t\treq2.Model = c.model\n\t}\n\n\treq2.Stream = true\n\treq2.Seed = 5\n\treq2.Temperature = 0.6\n\treq2.TopP = 0.95\n\n\tbody, err := json.Marshal(req2)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Bound to ctx so a stalled connection or silent server (headers or\n\t// stream body never arriving) is unblocked by the run's deadline\n\t// instead of hanging the goroutine forever — http.DefaultClient has\n\t// no timeout of its own.\n\tr, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+\"/chat/completions\", bytes.NewBuffer(body))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.Header.Set(\"Authorization\", \"Bearer \"+c.token)\n\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := c.httpClient.Do(r)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn NewStreamer(resp)\n}","start_line":1,"end_line":65,"total_lines":65,"truncated":false}
{"end_line": 100, "path": "internal/agentrun/loop.go", "start_line": 1}
{"path":"internal/agentrun/loop.go","content":"package agentrun\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\n\t\"github.com/abrander/zoo/internal/llm\"\n\t\"github.com/abrander/zoo/internal/tools\"\n)\n\n// Hooks lets a caller observe a runLoop invocation live, as the model\n// streams and tool calls execute, in addition to the final []llm.Message\n// it returns. Any of these may be nil.\ntype Hooks struct {\n\t// OnReasoningDelta and OnContentDelta fire with just the newly\n\t// streamed text for the current turn, not the accumulated total.\n\tOnReasoningDelta func(delta string)\n\tOnContentDelta func(delta string)\n\n\t// OnTurnEnd fires once per completed streamer round-trip, after the\n\t// model's message for that turn is fully received and before any of\n\t// its tool calls run.\n\tOnTurnEnd func()\n\n\t// OnTool fires once per tool call, after it has run.\n\tOnTool func(name, arguments, result string, toolErr bool)\n}\n\n// runLoop is a headless port of ../a's App.generate(): send messages +\n// tool defs, get a completion, run any tool_calls and append their\n// results, repeat until a plain finish or ctx is done.\nfunc runLoop(ctx context.Context, client *llm.Client, toolsCtx tools.Context, messages []llm.Message, hooks Hooks) ([]llm.Message, error) {\n\tfor {\n\t\tif err := ctx.Err(); err != nil {\n\t\t\treturn messages, err\n\t\t}\n\n\t\tstreamer, err := client.StreamChatCompletion(ctx, \u0026llm.ChatCompletionRequest{\n\t\t\tMessages: messages,\n\t\t\tStream: true,\n\t\t\tTools: tools.All(),\n\t\t})\n\t\tif err != nil {\n\t\t\treturn messages, fmt.Errorf(\"chat completion: %w\", err)\n\t\t}\n\n\t\tvar completion *llm.ChatCompletion\n\n\t\tvar prevContent, prevReasoning string\n\n\t\tfor {\n\t\t\tc, err := streamer.Get()\n\t\t\tif err == io.EOF {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\treturn messages, fmt.Errorf(\"stream completion: %w\", err)\n\t\t\t}\n\n\t\t\tcompletion = c\n\n\t\t\tif len(c.Choices) \u003e 0 {\n\t\t\t\tmsg := c.Choices[0].Message\n\n\t\t\t\tif hooks.OnReasoningDelta != nil \u0026\u0026 len(msg.ReasoningContent) \u003e len(prevReasoning) {\n\t\t\t\t\thooks.OnReasoningDelta(msg.ReasoningContent[len(prevReasoning):])\n\t\t\t\t}\n\t\t\t\tprevReasoning = msg.ReasoningContent\n\n\t\t\t\tif hooks.OnContentDelta != nil \u0026\u0026 len(msg.Content) \u003e len(prevContent) {\n\t\t\t\t\thooks.OnContentDelta(msg.Content[len(prevContent):])\n\t\t\t\t}\n\t\t\t\tprevContent = msg.Content\n\t\t\t}\n\t\t}\n\n\t\tif hooks.OnTurnEnd != nil {\n\t\t\thooks.OnTurnEnd()\n\t\t}\n\n\t\tif completion == nil || len(completion.Choices) == 0 {\n\t\t\treturn messages, fmt.Errorf(\"model returned an empty completion\")\n\t\t}\n\n\t\tchoice := completion.Choices[0]\n\t\tmessages = append(messages, choice.Message)\n\n\t\tif choice.FinishReason != \"tool_calls\" || len(choice.Message.ToolCalls) == 0 {\n\t\t\treturn messages, nil\n\t\t}\n\n\t\tfor _, tc := range choice.Message.ToolCalls {\n\t\t\tif err := ctx.Err(); err != nil {\n\t\t\t\treturn messages, err\n\t\t\t}\n\n\t\t\tresult, err := tools.Call(toolsCtx, tc.FunctionCall)\n","start_line":1,"end_line":100,"total_lines":118,"truncated":true}
{"end_line": 118, "path": "internal/agentrun/loop.go", "start_line": 100}
{"path":"internal/agentrun/loop.go","content":"\n\t\t\ttoolErr := err != nil\n\t\t\tif toolErr {\n\t\t\t\tresult = fmt.Sprintf(\"ERROR CALLING TOOL: %s\", err.Error())\n\t\t\t}\n\n\t\t\tif hooks.OnTool != nil {\n\t\t\t\thooks.OnTool(tc.FunctionCall.Name, tc.FunctionCall.Arguments, result, toolErr)\n\t\t\t}\n\n\t\t\tmessages = append(messages, llm.Message{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tContent: result,\n\t\t\t\tToolCallID: tc.Id,\n\t\t\t\tName: tc.FunctionCall.Name,\n\t\t\t})\n\t\t}\n\t}\n}","start_line":100,"end_line":118,"total_lines":118,"truncated":false}
{"end_line": 100, "path": "internal/llm/streamer.go", "start_line": 1}
{"path":"internal/llm/streamer.go","content":"package llm\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\ntype Streamer struct {\n\tcompletion ChatCompletion\n\tresp *http.Response\n\tscanner *bufio.Scanner\n\teof bool\n}\n\nfunc NewStreamer(resp *http.Response) (*Streamer, error) {\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\t\treturn nil, fmt.Errorf(\"status: %d: %s\", resp.StatusCode, body)\n\t}\n\n\treturn \u0026Streamer{\n\t\tresp: resp,\n\t\tscanner: bufio.NewScanner(resp.Body),\n\t}, nil\n}\n\n// Get returns the accumulated completion after merging in the next SSE\n// chunk. Callers should keep calling Get until it returns io.EOF.\nfunc (s *Streamer) Get() (*ChatCompletion, error) {\n\tif s.eof {\n\t\treturn nil, io.EOF\n\t}\n\n\tprefix := []byte(\"data: \")\n\n\tvar buf []byte\n\n\tfor s.scanner.Scan() {\n\t\tif err := s.scanner.Err(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tbuf = bytes.TrimSpace(s.scanner.Bytes())\n\n\t\tif len(buf) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tif bytes.HasPrefix(buf, prefix) {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tbuf = bytes.TrimPrefix(buf, prefix)\n\n\tif bytes.Equal(buf, []byte(\"[DONE]\")) {\n\t\ts.resp.Body.Close()\n\t\ts.eof = true\n\t\treturn nil, io.EOF\n\t}\n\n\tvar completion ChatCompletion\n\n\t// Unknown fields are tolerated on purpose: servers (e.g. newer\n\t// llama.cpp builds) may add fields we don't know about yet, and\n\t// failing on them would break compatibility.\n\tif err := json.Unmarshal(buf, \u0026completion); err != nil {\n\t\treturn nil, fmt.Errorf(\"decode completion chunk: %w: %s\", err, buf)\n\t}\n\n\ts.completion.Merge(\u0026completion)\n\n\treturn \u0026s.completion, nil\n}","start_line":1,"end_line":79,"total_lines":79,"truncated":false}
{"end_line": 100, "path": "internal/config/config.go", "start_line": 1}
{"path":"internal/config/config.go","content":"// Package config loads and validates zoo's HCL configuration file.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/hashicorp/hcl/v2/hclsimple\"\n)\n\n// Known event kinds. issue:assigned and pr:review are resolved\n// dynamically (agent name must match the Forgejo assignee's username,\n// or the pull request author's username, respectively) so they never\n// carry an `agent` attribute; the rest map statically to one\n// configured agent.\nconst (\n\tEventIssueNew = \"issue:new\"\n\tEventIssueComment = \"issue:comment\"\n\tEventIssueAssigned = \"issue:assigned\"\n\tEventPRNew = \"pr:new\"\n\tEventPRReview = \"pr:review\"\n)\n\nvar staticEventKinds = map[string]bool{\n\tEventIssueNew: true,\n\tEventIssueComment: true,\n\tEventPRNew: true,\n}\n\ntype Config struct {\n\tLLMs []LLM `hcl:\"llm,block\"`\n\tForgejo Forgejo `hcl:\"forgejo,block\"`\n\tEnvironment Environment `hcl:\"environment,block\"`\n\tAgents []Agent `hcl:\"agent,block\"`\n\tEvents []Event `hcl:\"event,block\"`\n\tWeb *Web `hcl:\"web,block\"`\n}\n\n// Web configures the dashboard's optional bearer-token gate. Leave the\n// block out of zoo.hcl entirely to run without one (fine on localhost;\n// put a real gate or a proxy in front for anything else).\ntype Web struct {\n\tToken string `hcl:\"token,optional\"`\n}\n\ntype LLM struct {\n\tName string `hcl:\"name,label\"`\n\tOpenAI string `hcl:\"openai\"`\n\tToken string `hcl:\"token\"`\n\tModel string `hcl:\"model\"`\n}\n\ntype Forgejo struct {\n\tURL string `hcl:\"url\"`\n\tToken string `hcl:\"token\"`\n\tWebhookSecret string `hcl:\"webhook_secret,optional\"`\n\n\t// Repos is the allowlist of repository patterns to watch, e.g.\n\t// [\"acme/*\", \"acme/widgets\"]. Patterns are \"owner/repo\" pairs with\n\t// glob wildcards; \"*\" watches everything on the instance. An empty\n\t// list keeps the historical behavior of watching every repository\n\t// the token can see.\n\tRepos []string `hcl:\"repos,optional\"`\n}\n\ntype Environment struct {\n\tDockerImage string `hcl:\"docker_image\"`\n\tMaxLive int `hcl:\"max_live_agents\"`\n}\n\ntype Agent struct {\n\tName string `hcl:\"name,label\"`\n\tLLM string `hcl:\"llm\"`\n\tToken string `hcl:\"token,optional\"`\n}\n\ntype Event struct {\n\tKind string `hcl:\"name,label\"`\n\tAgent string `hcl:\"agent,optional\"`\n\tInstructions string `hcl:\"instructions,optional\"`\n}\n\n// Load reads and validates the config file at path.\nfunc Load(path string) (*Config, error) {\n\tvar cfg Config\n\n\tif err := hclsimple.DecodeFile(path, nil, \u0026cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse config: %w\", err)\n\t}\n\n\tif err := cfg.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid config: %w\", err)\n\t}\n\n\treturn \u0026cfg, nil\n}\n\n// Validate checks that the config is internally consistent: every\n// reference between blocks resolves, and required values are set.\nfunc (c *Config) Validate() error {","start_line":1,"end_line":100,"total_lines":246,"truncated":true}
{"end_line": 80, "path": "internal/agentrun/run.go", "start_line": 1}
{"path":"internal/agentrun/run.go","content":"// Package agentrun creates the throwaway per-job environment an agent\n// runs in: a Docker container with the agent's git working tree cloned\n// inside it (authenticated with the agent's own Forgejo token), the\n// triggering event written to /event, and the tool-calling loop\n// (internal/llm + internal/tools) driven against it.\npackage agentrun\n\nimport (\n\t\"context\"\n\t_ \"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/forgejo\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/llm\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\n//go:embed system.md\nvar defaultSystemPrompt string\n\n// DefaultTimeout bounds a single agent run's wall-clock time if the\n// caller doesn't override it.\nconst DefaultTimeout = 120 * time.Minute\n\ntype Runner struct {\n\tdocker *dockerRuntime\n\tforgejo *forgejo.Client\n\tstore *store.Store\n\thub *livelog.Hub\n\tcfg *config.Config\n\tlogger *slog.Logger\n\ttimeout time.Duration\n\tkeepOnFailure bool\n\n\tagentClientsMu sync.Mutex\n\tagentClients map[string]*forgejo.Client\n}\n\nfunc NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {\n\tdocker, err := newDockerRuntime()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif timeout \u003c= 0 {\n\t\ttimeout = DefaultTimeout\n\t}\n\n\treturn \u0026Runner{\n\t\tdocker: docker,\n\t\tforgejo: fg,\n\t\tstore: st,\n\t\thub: hub,\n\t\tcfg: cfg,\n\t\tlogger: logger,\n\t\ttimeout: timeout,\n\t\tkeepOnFailure: keepOnFailure,\n\t\tagentClients: make(map[string]*forgejo.Client),\n\t}, nil\n}\n\n// forgejoAs returns a Forgejo client that authenticates as the given\n// agent (using the agent's own token from config). This lets each agent\n// act as themselves on Forgejo without needing a global token with sudo\n// privileges. Clients are built once per agent and cached, since\n// constructing one costs an extra API round trip.\n//\n// If the agent has no token configured, falls back to the shared zoo\n// identity so existing deployments without per-agent tokens still work.\nfunc (r *Runner) forgejoAs(agentName, token string) *forgejo.Client {\n\tr.agentClientsMu.Lock()","start_line":1,"end_line":80,"total_lines":437,"truncated":true}
{"query": "runLoop|StreamChatCompletion|NewClient"}}
ERROR CALLING TOOL: invalid character '}' after top-level value