Details and log output for this agent run.
{"path": ".", "recursive": true}
/project/cmd /project/cmd/zoo /project/cmd/zoo/main.go /project/go.mod /project/go.sum /project/internal /project/internal/agentrun /project/internal/agentrun/clone.go /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/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/event.go /project/internal/forgejo/poll.go /project/internal/forgejo/watcher.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/git.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 -10 && git status"}
1c33680 (HEAD -> issue-28-greg, origin/main, origin/HEAD, main) Merge pull request 'Move max_live_agents into the environment block' (#23) from issue-22-mika into main 7cf22ff Move max_live_agents into the environment block 94b8ca1 Merge pull request 'Add per-agent Forgejo tokens, deprecate Sudo: header approach' (#21) from issue-16-mika into main f8c45dc Merge pull request 'web: show agent profile photos on the jobs pages' (#20) from issue-17-greg into main 4a31b24 Add per-agent Forgejo tokens, deprecate Sudo: header approach e1bf291 web: show agent profile photos on the jobs pages 6565a52 Use base64 when reading files. TTY can fuck up special characters. f348d6c Merge pull request 'Fix TestIdentitySectionIncludesSelfAndOthers: align test with actual roster rendering' (#19) from issue-18-greg into main 5329c37 test(agentrun): align identitySection roster assertions with actual rendering 2ea3094 Better assignee tracking. On branch issue-28-greg nothing to commit, working tree clean
{"path": "internal/tools/forgejo.go", "start_line": 1, "end_line": 200}
{"path":"internal/tools/forgejo.go","content":"package tools\r\n\r\nimport (\r\n\t\"fmt\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\ntype commentParams struct {\r\n\tBody string `json:\"body\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"comment\",\r\n\t\t\"Post a comment on the issue or pull request that triggered this run.\")\r\n\r\n\ttool.AddStringProperty(\"body\", \"The comment text, in Markdown\", true)\r\n\r\n\tRegister(tool, comment)\r\n}\r\n\r\nfunc comment(ctx Context, params commentParams) (string, error) {\r\n\tif err := ctx.Forgejo().Comment(params.Body); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Comment posted\", nil\r\n}\r\n\r\ntype openPullRequestParams struct {\r\n\tHead string `json:\"head\"`\r\n\tBase string `json:\"base\"`\r\n\tTitle string `json:\"title\"`\r\n\tBody string `json:\"body\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"open_pull_request\",\r\n\t\t\"Open a pull request from a branch you've pushed into the repository's default branch (or another base branch).\")\r\n\r\n\ttool.AddStringProperty(\"head\", \"The branch containing your changes (usually the run's own branch)\", true)\r\n\ttool.AddStringProperty(\"base\", \"The branch to merge into, e.g. the repository's default branch\", true)\r\n\ttool.AddStringProperty(\"title\", \"The pull request title\", true)\r\n\ttool.AddStringProperty(\"body\", \"The pull request description, in Markdown\", true)\r\n\r\n\tRegister(tool, openPullRequest)\r\n}\r\n\r\nfunc openPullRequest(ctx Context, params openPullRequestParams) (string, error) {\r\n\tif err := ctx.Forgejo().OpenPullRequest(params.Head, params.Base, params.Title, params.Body); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Pull request opened\", nil\r\n}\r\n\r\ntype requestReviewParams struct {\r\n\tReviewers []string `json:\"reviewers\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"request_review\",\r\n\t\t\"Request review of the current pull request from one or more Forgejo users.\")\r\n\r\n\ttool.AddStringArrayProperty(\"reviewers\", \"Usernames to request review from\", true)\r\n\r\n\tRegister(tool, requestReview)\r\n}\r\n\r\nfunc requestReview(ctx Context, params requestReviewParams) (string, error) {\r\n\tif err := ctx.Forgejo().RequestReview(params.Reviewers); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Review requested\", nil\r\n}\r\n\r\ntype labelParams struct {\r\n\tName string `json:\"name\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"add_label\",\r\n\t\t\"Add a label to the issue or pull request that triggered this run, creating it on the repo first if needed.\")\r\n\r\n\ttool.AddStringProperty(\"name\", \"The label name\", true)\r\n\r\n\tRegister(tool, addLabel)\r\n}\r\n\r\nfunc addLabel(ctx Context, params labelParams) (string, error) {\r\n\tif err := ctx.Forgejo().AddLabel(params.Name); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Label added\", nil\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"remove_label\",\r\n\t\t\"Remove a label from the issue or pull request that triggered this run.\")\r\n\r\n\ttool.AddStringProperty(\"name\", \"The label name\", true)\r\n\r\n\tRegister(tool, removeLabel)\r\n}\r\n\r\nfunc removeLabel(ctx Context, params labelParams) (string, error) {\r\n\tif err := ctx.Forgejo().RemoveLabel(params.Name); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Label removed\", nil\r\n}\r\n\r\ntype assignIssueParams struct {\r\n\tAgent string `json:\"agent\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"assign_issue\",\r\n\t\t\"Hand the issue or pull request that triggered this run off to another agent, by name. This assigns it to that agent's Forgejo account, which triggers their run. Use this when the task is better suited to another agent's described role than your own.\")\r\n\r\n\ttool.AddStringProperty(\"agent\", \"The name of the agent to hand this off to, e.g. one listed in the \\\"Other agents\\\" section of your system prompt\", true)\r\n\r\n\tRegister(tool, assignIssue)\r\n}\r\n\r\nfunc assignIssue(ctx Context, params assignIssueParams) (string, error) {\r\n\tif err := ctx.Forgejo().AssignIssue(params.Agent); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn fmt.Sprintf(\"Assigned to %s\", params.Agent), nil\r\n}\r\n\r\ntype noParams struct{}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"close_issue\",\r\n\t\t\"Close the issue or pull request that triggered this run.\")\r\n\r\n\tRegister(tool, closeIssue)\r\n}\r\n\r\nfunc closeIssue(ctx Context, _ noParams) (string, error) {\r\n\tif err := ctx.Forgejo().CloseIssue(); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Issue closed\", nil\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"reopen_issue\",\r\n\t\t\"Reopen the issue or pull request that triggered this run.\")\r\n\r\n\tRegister(tool, reopenIssue)\r\n}\r\n\r\nfunc reopenIssue(ctx Context, _ noParams) (string, error) {\r\n\tif err := ctx.Forgejo().ReopenIssue(); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Issue reopened\", nil\r\n}\r","start_line":1,"end_line":175,"total_lines":175,"truncated":false}
{"path": "internal/tools/registry.go", "start_line": 1, "end_line": 120}
{"path":"internal/tools/registry.go","content":"package tools\r\n\r\nimport (\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\ntype toolFunc[P any] func(Context, P) (string, error)\r\n\r\ntype tool struct {\r\n\ttool llm.Tool\r\n\tcall func(ctx Context, args string) (string, error)\r\n}\r\n\r\nvar registry = map[string]tool{}\r\n\r\n// All lists all known tools.\r\nfunc All() []llm.Tool {\r\n\tts := make([]llm.Tool, 0, len(registry))\r\n\r\n\tfor _, t := range registry {\r\n\t\tts = append(ts, t.tool)\r\n\t}\r\n\r\n\treturn ts\r\n}\r\n\r\n// Register registers a tool function with the given definition. The\r\n// registry is a stateless name-\u003efunc map shared across concurrent runs;\r\n// all per-run state lives in the Context passed to Call.\r\nfunc Register[P any](def llm.Tool, fun toolFunc[P]) {\r\n\tif def.Type != \"function\" {\r\n\t\tpanic(\"tool must be a function\")\r\n\t}\r\n\r\n\tif def.Function.Name == \"\" {\r\n\t\tpanic(\"tool function must have a name\")\r\n\t}\r\n\r\n\tif _, found := registry[def.Function.Name]; found {\r\n\t\tpanic(fmt.Sprintf(\"tool with name %q already registered\", def.Function.Name))\r\n\t}\r\n\r\n\tregistry[def.Function.Name] = tool{\r\n\t\ttool: def,\r\n\t\tcall: func(ctx Context, args string) (string, error) {\r\n\t\t\tvar params P\r\n\r\n\t\t\tif err := json.Unmarshal([]byte(args), \u0026params); err != nil {\r\n\t\t\t\treturn \"\", err\r\n\t\t\t}\r\n\r\n\t\t\treturn fun(ctx, params)\r\n\t\t},\r\n\t}\r\n}\r\n\r\n// Call invokes a registered tool by name with the given (raw JSON)\r\n// arguments, against the given run's Context.\r\nfunc Call(ctx Context, fcall llm.FunctionCall) (string, error) {\r\n\tt, found := registry[fcall.Name]\r\n\tif !found {\r\n\t\treturn \"\", fmt.Errorf(\"unknown function: %s\", fcall.Name)\r\n\t}\r\n\r\n\treturn t.call(ctx, fcall.Arguments)\r\n}\r","start_line":1,"end_line":69,"total_lines":69,"truncated":false}
{"path": "internal/tools/context.go", "start_line": 1, "end_line": 120}
{"path":"internal/tools/context.go","content":"// Package tools is the registry of functions an agent's tool-calling\r\n// loop can invoke, ported from ../a/tools but retargeted at a Docker\r\n// container (via Exec) and the Forgejo issue/PR that triggered the run\r\n// (via Forgejo()) instead of a local sandboxed filesystem.\r\npackage tools\r\n\r\nimport \"context\"\r\n\r\n// Context is bound to a single agent run (job), unlike ../a's single\r\n// global Context, since multiple agents run concurrently in zoo.\r\ntype Context interface {\r\n\t// Exec runs command inside the run's container via `sh -c` and\r\n\t// returns combined stdout+stderr. The container never holds a\r\n\t// Forgejo credential, so this cannot reach the Forgejo API or\r\n\t// authenticate git operations against it — see Git.\r\n\tExec(ctx context.Context, command string) (string, error)\r\n\r\n\t// Git runs a git subcommand against the run's working tree on the\r\n\t// host, outside the container, so that operations needing a Forgejo\r\n\t// credential (currently just push) can authenticate without that\r\n\t// credential ever being written to disk where the container (and\r\n\t// anything run inside it via Exec) could read it.\r\n\tGit(ctx context.Context, subcommand string, args ...string) (string, error)\r\n\r\n\t// Forgejo returns the actions bound to the issue/PR that triggered\r\n\t// this run, so tools don't need to be told which repo/issue to act\r\n\t// on.\r\n\tForgejo() ForgejoActions\r\n}\r\n\r\n// ForgejoActions are the actions available to an agent against the\r\n// triggering issue/PR. Implemented by internal/agentrun, backed by\r\n// internal/forgejo.Client plus the run's owner/repo/issue.\r\ntype ForgejoActions interface {\r\n\tComment(body string) error\r\n\tOpenPullRequest(head, base, title, body string) error\r\n\tRequestReview(reviewers []string) error\r\n\tAddLabel(name string) error\r\n\tRemoveLabel(name string) error\r\n\tCloseIssue() error\r\n\tReopenIssue() error\r\n\r\n\t// AssignIssue hands the triggering issue/PR off to another agent by\r\n\t// their name (which doubles as their Forgejo username); the\r\n\t// resulting issue:assigned event triggers that agent's run.\r\n\tAssignIssue(agentName string) error\r\n}\r","start_line":1,"end_line":47,"total_lines":47,"truncated":false}
{"path": "internal/forgejo/client.go", "start_line": 1, "end_line": 250}
{"path":"internal/forgejo/client.go","content":"// Package forgejo wraps the Forgejo SDK and turns webhook deliveries and\r\n// polling results into a common Event stream for the scheduler.\r\npackage forgejo\r\n\r\nimport (\r\n\t\"fmt\"\r\n\r\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n)\r\n\r\n// Client is zoo's single shared Forgejo identity, used both for the\r\n// event sources (webhook/poll) and for actions agents/scheduler take\r\n// (comments, labels, PRs).\r\ntype Client struct {\r\n\tsdk *sdk.Client\r\n\r\n\tbaseURL string\r\n\ttoken string\r\n}\r\n\r\nfunc NewClient(cfg config.Forgejo) (*Client, error) {\r\n\tc, err := sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"forgejo client: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026Client{sdk: c, baseURL: cfg.URL, token: cfg.Token}, nil\r\n}\r\n\r\n// Token returns the shared zoo Forgejo identity's token, e.g. for\r\n// authenticating a host-side git clone/push against Forgejo (see\r\n// internal/agentrun) without ever writing the credential into a working\r\n// tree an agent's container can read.\r\nfunc (c *Client) Token() string {\r\n\treturn c.token\r\n}\r\n\r\n// As returns a new Client that authenticates as the given token.\r\n// This is used to create per-agent clients so each agent acts as\r\n// themselves on Forgejo, without needing a global token with sudo\r\n// privileges.\r\nfunc (c *Client) As(token string) *Client {\r\n\tclient, _ := sdk.NewClient(c.baseURL, sdk.SetToken(token))\r\n\treturn \u0026Client{sdk: client, baseURL: c.baseURL, token: token}\r\n}\r\n\r\n// Sudo returns a new Client that impersonates username (via Forgejo's\r\n// \"Sudo:\" header) on every API call it makes, using the same underlying\r\n// token. Actions an agent takes through it — comments, labels, PRs,\r\n// assignment — are attributed to that agent's own Forgejo account\r\n// instead of the shared zoo identity. The token must belong to a user\r\n// with sudo scope/admin rights for this to work; Forgejo rejects the\r\n// header otherwise.\r\n//\r\n// Deprecated: use As(token) with a per-agent token instead. Kept for\r\n// backward compatibility during migration.\r\nfunc (c *Client) Sudo(username string) (*Client, error) {\r\n\tsudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"forgejo client sudo %q: %w\", username, err)\r\n\t}\r\n\r\n\treturn \u0026Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil\r\n}\r\n\r\n// CreateIssueComment posts a comment on the given issue or pull request\r\n// (Forgejo/Gitea treat PRs as issues for commenting purposes).\r\nfunc (c *Client) CreateIssueComment(owner, repo string, index int64, body string) error {\r\n\t_, _, err := c.sdk.CreateIssueComment(owner, repo, index, sdk.CreateIssueCommentOption{Body: body})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"comment on %s/%s#%d: %w\", owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AddLabel attaches the label with the given name to an issue/PR,\r\n// creating the label (with a default color) on the repo first if it\r\n// doesn't already exist.\r\nfunc (c *Client) AddLabel(owner, repo string, index int64, name string) error {\r\n\tid, err := c.labelID(owner, repo, name)\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\r\n\t_, _, err = c.sdk.AddIssueLabels(owner, repo, index, sdk.IssueLabelsOption{Labels: []int64{id}})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"add label %q to %s/%s#%d: %w\", name, owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// RemoveLabel detaches the label with the given name from an issue/PR, if\r\n// both the label and the attachment exist.\r\nfunc (c *Client) RemoveLabel(owner, repo string, index int64, name string) error {\r\n\tlabels, _, err := c.sdk.GetIssueLabels(owner, repo, index, sdk.ListLabelsOptions{})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"list labels on %s/%s#%d: %w\", owner, repo, index, err)\r\n\t}\r\n\r\n\tfor _, l := range labels {\r\n\t\tif l.Name == name {\r\n\t\t\t_, err := c.sdk.DeleteIssueLabel(owner, repo, index, l.ID)\r\n\t\t\tif err != nil {\r\n\t\t\t\treturn fmt.Errorf(\"remove label %q from %s/%s#%d: %w\", name, owner, repo, index, err)\r\n\t\t\t}\r\n\t\t\treturn nil\r\n\t\t}\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\nfunc (c *Client) labelID(owner, repo, name string) (int64, error) {\r\n\tlabels, _, err := c.sdk.ListRepoLabels(owner, repo, sdk.ListLabelsOptions{})\r\n\tif err != nil {\r\n\t\treturn 0, fmt.Errorf(\"list labels on %s/%s: %w\", owner, repo, err)\r\n\t}\r\n\r\n\tfor _, l := range labels {\r\n\t\tif l.Name == name {\r\n\t\t\treturn l.ID, nil\r\n\t\t}\r\n\t}\r\n\r\n\tcreated, _, err := c.sdk.CreateLabel(owner, repo, sdk.CreateLabelOption{\r\n\t\tName: name,\r\n\t\tColor: \"#ee0000\",\r\n\t})\r\n\tif err != nil {\r\n\t\treturn 0, fmt.Errorf(\"create label %q on %s/%s: %w\", name, owner, repo, err)\r\n\t}\r\n\r\n\treturn created.ID, nil\r\n}\r\n\r\n// CreatePullRequest opens a PR from head into base.\r\nfunc (c *Client) CreatePullRequest(owner, repo, head, base, title, body string) error {\r\n\t_, _, err := c.sdk.CreatePullRequest(owner, repo, sdk.CreatePullRequestOption{\r\n\t\tHead: head,\r\n\t\tBase: base,\r\n\t\tTitle: title,\r\n\t\tBody: body,\r\n\t})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"create pull request %s/%s %s-\u003e%s: %w\", owner, repo, head, base, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// RequestReview asks the given users to review the pull request.\r\nfunc (c *Client) RequestReview(owner, repo string, index int64, reviewers []string) error {\r\n\t_, err := c.sdk.CreateReviewRequests(owner, repo, index, sdk.PullReviewRequestOptions{Reviewers: reviewers})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"request review on %s/%s#%d: %w\", owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// CloseIssue closes the given issue or pull request.\r\nfunc (c *Client) CloseIssue(owner, repo string, index int64) error {\r\n\treturn c.setIssueState(owner, repo, index, sdk.StateClosed)\r\n}\r\n\r\n// ReopenIssue reopens the given issue or pull request.\r\nfunc (c *Client) ReopenIssue(owner, repo string, index int64) error {\r\n\treturn c.setIssueState(owner, repo, index, sdk.StateOpen)\r\n}\r\n\r\n// RepositoryInfo returns the pieces of repo metadata agentrun needs to\r\n// clone and branch off of the right place.\r\ntype RepositoryInfo struct {\r\n\tDefaultBranch string\r\n\tCloneURL string\r\n}\r\n\r\nfunc (c *Client) RepositoryInfo(owner, repo string) (RepositoryInfo, error) {\r\n\tr, _, err := c.sdk.GetRepo(owner, repo)\r\n\tif err != nil {\r\n\t\treturn RepositoryInfo{}, fmt.Errorf(\"get repo %s/%s: %w\", owner, repo, err)\r\n\t}\r\n\r\n\treturn RepositoryInfo{DefaultBranch: r.DefaultBranch, CloneURL: r.CloneURL}, nil\r\n}\r\n\r\nfunc (c *Client) setIssueState(owner, repo string, index int64, state sdk.StateType) error {\r\n\t_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{State: \u0026state})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"set state %q on %s/%s#%d: %w\", state, owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AgentProfile is what zoo reads off an agent's Forgejo account: its bio\r\n// (surfaced in the system prompt), the name/email used to set git commit\r\n// authorship inside that agent's container, and its avatar URL (surfaced\r\n// on the dashboard).\r\ntype AgentProfile struct {\r\n\tDescription string\r\n\tFullName string\r\n\tEmail string\r\n\tAvatarURL string\r\n}\r\n\r\n// AgentProfile fetches the Forgejo profile of the user account named\r\n// username. Agent config labels double as their Forgejo usernames (see\r\n// issue:assigned resolution), so this is how an agent's \"what it does\"\r\n// text, and its commit identity, are authored: by editing that account's\r\n// profile on Forgejo, not in zoo.hcl.\r\nfunc (c *Client) AgentProfile(username string) (AgentProfile, error) {\r\n\tu, _, err := c.sdk.GetUserInfo(username)\r\n\tif err != nil {\r\n\t\treturn AgentProfile{}, fmt.Errorf(\"get user %q: %w\", username, err)\r\n\t}\r\n\r\n\tp := AgentProfile{\r\n\t\tDescription: u.Description,\r\n\t\tFullName: u.FullName,\r\n\t\tEmail: u.Email,\r\n\t\tAvatarURL: u.AvatarURL,\r\n\t}\r\n\r\n\treturn p, nil\r\n}\r\n\r\n// AssignIssue sets the sole assignee of an issue/PR to username. Used by\r\n// the assign_issue tool so agents can hand work off to each other: the\r\n// resulting issue:assigned event (webhook or poll) triggers whichever\r\n// agent's config label matches username.\r\nfunc (c *Client) AssignIssue(owner, repo string, index int64, username string) error {\r\n\t_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{Assignees: []string{username}})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"assign %s/%s#%d to %q: %w\", owner, repo, index, username, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r","start_line":1,"end_line":243,"total_lines":243,"truncated":false}
{"path": "internal/agentrun/forgejo.go", "start_line": 1, "end_line": 250}
{"path":"internal/agentrun/forgejo.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"log/slog\"\r\n\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n)\r\n\r\n// runForgejoActions implements tools.ForgejoActions, binding the\r\n// generic forgejo.Client to the single issue/PR that triggered this run\r\n// so agent tool calls don't need to specify owner/repo/index themselves.\r\ntype runForgejoActions struct {\r\n\tclient *forgejo.Client\r\n\towner, repo string\r\n\tindex int64\r\n\tlogger *slog.Logger\r\n}\r\n\r\n// logResult logs a forge action once it's known to have succeeded or\r\n// failed, so every comment, PR, label, or issue-state change an agent\r\n// makes is traceable in zoo's own logs, not just in Forgejo's history.\r\nfunc (a *runForgejoActions) logResult(action string, err error, args ...any) {\r\n\tfields := append([]any{\"owner\", a.owner, \"repo\", a.repo, \"index\", a.index}, args...)\r\n\r\n\tif err != nil {\r\n\t\ta.logger.Warn(\"forge action failed\", append(append([]any{\"action\", action}, fields...), \"error\", err)...)\r\n\t\treturn\r\n\t}\r\n\r\n\ta.logger.Info(\"forge action\", append([]any{\"action\", action}, fields...)...)\r\n}\r\n\r\nfunc (a *runForgejoActions) Comment(body string) error {\r\n\terr := a.client.CreateIssueComment(a.owner, a.repo, a.index, body)\r\n\ta.logResult(\"comment\", err)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) OpenPullRequest(head, base, title, body string) error {\r\n\terr := a.client.CreatePullRequest(a.owner, a.repo, head, base, title, body)\r\n\ta.logResult(\"open_pull_request\", err, \"head\", head, \"base\", base, \"title\", title)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) RequestReview(reviewers []string) error {\r\n\terr := a.client.RequestReview(a.owner, a.repo, a.index, reviewers)\r\n\ta.logResult(\"request_review\", err, \"reviewers\", reviewers)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) AddLabel(name string) error {\r\n\terr := a.client.AddLabel(a.owner, a.repo, a.index, name)\r\n\ta.logResult(\"add_label\", err, \"label\", name)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) RemoveLabel(name string) error {\r\n\terr := a.client.RemoveLabel(a.owner, a.repo, a.index, name)\r\n\ta.logResult(\"remove_label\", err, \"label\", name)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) CloseIssue() error {\r\n\terr := a.client.CloseIssue(a.owner, a.repo, a.index)\r\n\ta.logResult(\"close_issue\", err)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) ReopenIssue() error {\r\n\terr := a.client.ReopenIssue(a.owner, a.repo, a.index)\r\n\ta.logResult(\"reopen_issue\", err)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) AssignIssue(agentName string) error {\r\n\terr := a.client.AssignIssue(a.owner, a.repo, a.index, agentName)\r\n\ta.logResult(\"assign_issue\", err, \"assignee\", agentName)\r\n\r\n\treturn err\r\n}\r","start_line":1,"end_line":87,"total_lines":87,"truncated":false}
{"path": "internal/agentrun/run.go", "start_line": 1, "end_line": 250}
{"path":"internal/agentrun/run.go","content":"// Package agentrun creates the throwaway per-job environment an agent\r\n// runs in: a git clone + branch bind-mounted into a Docker container,\r\n// the triggering event written to /event, and the tool-calling loop\r\n// (internal/llm + internal/tools) driven against it.\r\npackage agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t_ \"embed\"\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"os\"\r\n\t\"path/filepath\"\r\n\t\"strings\"\r\n\t\"sync\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\n//go:embed system.md\r\nvar defaultSystemPrompt string\r\n\r\n// DefaultTimeout bounds a single agent run's wall-clock time if the\r\n// caller doesn't override it.\r\nconst DefaultTimeout = 120 * time.Minute\r\n\r\ntype Runner struct {\r\n\tdocker *dockerRuntime\r\n\tforgejo *forgejo.Client\r\n\tstore *store.Store\r\n\thub *livelog.Hub\r\n\tcfg *config.Config\r\n\tlogger *slog.Logger\r\n\ttimeout time.Duration\r\n\tkeepOnFailure bool\r\n\r\n\tagentClientsMu sync.Mutex\r\n\tagentClients map[string]*forgejo.Client\r\n}\r\n\r\nfunc NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {\r\n\tdocker, err := newDockerRuntime()\r\n\tif err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\r\n\tif timeout \u003c= 0 {\r\n\t\ttimeout = DefaultTimeout\r\n\t}\r\n\r\n\treturn \u0026Runner{\r\n\t\tdocker: docker,\r\n\t\tforgejo: fg,\r\n\t\tstore: st,\r\n\t\thub: hub,\r\n\t\tcfg: cfg,\r\n\t\tlogger: logger,\r\n\t\ttimeout: timeout,\r\n\t\tkeepOnFailure: keepOnFailure,\r\n\t\tagentClients: make(map[string]*forgejo.Client),\r\n\t}, nil\r\n}\r\n\r\n// forgejoAs returns a Forgejo client that authenticates as the given\r\n// agent (using the agent's own token from config). This lets each agent\r\n// act as themselves on Forgejo without needing a global token with sudo\r\n// privileges. Clients are built once per agent and cached, since\r\n// constructing one costs an extra API round trip.\r\n//\r\n// If the agent has no token configured, falls back to the shared zoo\r\n// identity so existing deployments without per-agent tokens still work.\r\nfunc (r *Runner) forgejoAs(agentName, token string) *forgejo.Client {\r\n\tr.agentClientsMu.Lock()\r\n\tdefer r.agentClientsMu.Unlock()\r\n\r\n\tif c, ok := r.agentClients[agentName]; ok {\r\n\t\treturn c\r\n\t}\r\n\r\n\tvar c *forgejo.Client\r\n\tif token != \"\" {\r\n\t\tc = r.forgejo.As(token)\r\n\t} else {\r\n\t\t// Fallback: use shared identity. Optionally log a warning\r\n\t\t// if we ever want to enforce per-agent tokens.\r\n\t\tc = r.forgejo\r\n\t}\r\n\r\n\tr.agentClients[agentName] = c\r\n\r\n\treturn c\r\n}\r\n\r\n// Run implements scheduler.Runner.\r\nfunc (r *Runner) Run(ctx context.Context, jobID string, agent config.AgentConfig, llmCfg config.LLM, dockerImage string, ev forgejo.Event) error {\r\n\tctx, cancel := context.WithTimeout(ctx, r.timeout)\r\n\tdefer cancel()\r\n\r\n\tlogger := r.logger.With(\"job\", jobID, \"agent\", agent.Name)\r\n\r\n\trepoInfo, err := r.forgejo.RepositoryInfo(ev.Owner, ev.Repo)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"look up repository: %w\", err)\r\n\t}\r\n\r\n\tworkDir, err := os.MkdirTemp(\"\", \"zoo-run-*\")\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"create work dir: %w\", err)\r\n\t}\r\n\r\n\tsucceeded := false\r\n\r\n\tdefer func() {\r\n\t\tif succeeded || !r.keepOnFailure {\r\n\t\t\tos.RemoveAll(workDir)\r\n\t\t} else {\r\n\t\t\tlogger.Warn(\"keeping work dir after failure\", \"dir\", workDir)\r\n\t\t}\r\n\t}()\r\n\r\n\tbranch := fmt.Sprintf(\"issue-%d-%s\", ev.Index, agent.Name)\r\n\tprojectDir := filepath.Join(workDir, \"project\")\r\n\r\n\tif err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil {\r\n\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\r\n\t}\r\n\r\n\troster := buildRoster(r.forgejo, r.cfg.Agents, logger)\r\n\tgitName, gitEmail := gitIdentity(agent.Name, roster)\r\n\r\n\t// Local (not --global) scope, so this identity lives in\r\n\t// projectDir/.git/config: the one place both this host-side clone\r\n\t// and the container it's bind-mounted into (as /project) actually\r\n\t// share.\r\n\tif out, err := runGit(ctx, projectDir, \"config\", \"user.name\", gitName); err != nil {\r\n\t\treturn fmt.Errorf(\"configure git user.name: %w: %s\", err, out)\r\n\t}\r\n\tif out, err := runGit(ctx, projectDir, \"config\", \"user.email\", gitEmail); err != nil {\r\n\t\treturn fmt.Errorf(\"configure git user.email: %w: %s\", err, out)\r\n\t}\r\n\r\n\teventPath := filepath.Join(workDir, \"event.json\")\r\n\tif err := os.WriteFile(eventPath, ev.Raw, 0o644); err != nil {\r\n\t\treturn fmt.Errorf(\"write event file: %w\", err)\r\n\t}\r\n\r\n\tcontainerID, err := r.docker.createContainer(ctx, dockerImage, []string{\r\n\t\tprojectDir + \":/project\",\r\n\t\teventPath + \":/event:ro\",\r\n\t}, fmt.Sprintf(\"zoo-issue-%d-%s\", ev.Index, agent.Name))\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"start container: %w\", err)\r\n\t}\r\n\r\n\tdefer func() {\r\n\t\tcleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)\r\n\t\tdefer cleanupCancel()\r\n\t\tif err := r.docker.remove(cleanupCtx, containerID); err != nil {\r\n\t\t\tlogger.Warn(\"failed to remove container\", \"container\", containerID, \"error\", err)\r\n\t\t}\r\n\t}()\r\n\r\n\t// /project is bind-mounted from the host, so it's owned by the host\r\n\t// UID that ran the clone, not whatever UID runs inside the\r\n\t// container (usually root) — git's ownership check rejects that by\r\n\t// default (\"detected dubious ownership\") unless told otherwise.\r\n\t// --system (not --global) so this holds regardless of which user\r\n\t// subsequent `docker exec` calls run as. Commit identity is\r\n\t// configured host-side, above, with --local scope so it's visible\r\n\t// from both sides of the bind mount without needing --global here.\r\n\tout, exitCode, err := r.docker.exec(ctx, containerID, \"git config --system --add safe.directory '*'\")\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"configure git safe.directory in container: %w: %s\", err, out)\r\n\t}\r\n\tif exitCode != 0 {\r\n\t\treturn fmt.Errorf(\"configure git safe.directory in container: exit %d: %s\", exitCode, out)\r\n\t}\r\n\r\n\tlogAppend := func(stream, line string) {\r\n\t\tif err := r.store.AppendLog(context.Background(), jobID, stream, line); err != nil {\r\n\t\t\tlogger.Warn(\"failed to append log\", \"error\", err)\r\n\t\t}\r\n\t}\r\n\r\n\trunCtx := \u0026runContext{\r\n\t\tdocker: r.docker,\r\n\t\tcontainerID: containerID,\r\n\t\tprojectDir: projectDir,\r\n\t\ttoken: r.forgejo.Token(),\r\n\t\tforgejo: \u0026runForgejoActions{\r\n\t\t\tclient: r.forgejoAs(agent.Name, agent.Token),\r\n\t\t\towner: ev.Owner,\r\n\t\t\trepo: ev.Repo,\r\n\t\t\tindex: ev.Index,\r\n\t\t\tlogger: logger,\r\n\t\t},\r\n\t}\r\n\r\n\tllmClient := llm.NewClient(llmCfg)\r\n\r\n\tsystemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)\r\n\r\n\tinstructions := r.cfg.EventInstructions(ev.Kind)\r\n\r\n\tmessages := []llm.Message{\r\n\t\t{Role: \"system\", Content: systemPrompt},\r\n\t\t{Role: \"user\", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions)},\r\n\t}\r\n\r\n\thooks := r.streamHooks(jobID, logAppend)\r\n\r\n\t_, err = runLoop(ctx, llmClient, runCtx, messages, hooks)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"agent loop: %w\", err)\r\n\t}\r\n\r\n\tsucceeded = true\r\n\r\n\treturn nil\r\n}\r\n\r\n// streamHooks builds the Hooks a single Run passes to runLoop: every\r\n// delta is published live to the hub for connected dashboard viewers,\r\n// and once a reasoning/content block or tool call is complete, it's\r\n// persisted to the store as one row and the hub's replay buffer for\r\n// jobID is checkpointed — so a viewer connecting from this point on\r\n// sees it via the persisted history instead of a live replay, and is\r\n// never shown it twice.\r\nfunc (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {\r\n\tvar reasoningBuf, contentBuf strings.Builder\r\n\r\n\treasoningOpen, contentOpen := false, false\r\n\r\n\treturn Hooks{\r\n\t\tOnReasoningDelta: func(delta string) {\r\n\t\t\tif !reasoningOpen {\r\n\t\t\t\treasoningOpen = true\r\n\t\t\t\treasoningBuf.Reset()\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})\r\n\t\t\t}\r\n\r\n\t\t\treasoningBuf.WriteString(delta)\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})\r\n\t\t},\r","start_line":1,"end_line":250,"total_lines":310,"truncated":true}
{"command": "cd /project && grep -rn \"CreateIssue\\b\" $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2*/issue*.go 2>/dev/null | head -20; ls $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/ 2>/dev/null"}
ERROR CALLING TOOL: command failed: command exited with status 2
{"command": "cd /project && go env GOMODCACHE && grep \"forgejo-sdk\" go.mod"}
/go/pkg/mod codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
{"command": "cd /project && grep -rn \"func (c \\*Client) CreateIssue\" /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/ | head -20"}
grep: /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/: No such file or directory
{"command": "cd /project && go list -m -f '{{.Dir}}' codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"}
{"command": "cd /project && go mod download codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 2>&1 | head; go list -m -f '{{.Dir}}' codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"}
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0
{"command": "cd /project && grep -rn \"func (c \\*Client) CreateIssue\" /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/ | head -20"}
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue.go:238:func (c *Client) CreateIssue(owner, repo string, opt CreateIssueOption) (*Issue, *Response, error) {
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue_comment.go:105:func (c *Client) CreateIssueComment(owner, repo string, index int64, opt CreateIssueCommentOption) (*Comment, *Response, error) {
{"command": "cd /project && sed -n '220,260p' /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue.go && grep -n \"type CreateIssueOption\" -A 30 /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue.go"}
Assignees []string `json:"assignees"`
Deadline *time.Time `json:"due_date"`
// milestone id
Milestone int64 `json:"milestone"`
// list of label ids
Labels []int64 `json:"labels"`
Closed bool `json:"closed"`
}
// Validate the CreateIssueOption struct
func (opt CreateIssueOption) Validate() error {
if len(strings.TrimSpace(opt.Title)) == 0 {
return fmt.Errorf("title is empty")
}
return nil
}
// CreateIssue create a new issue for a given repository
func (c *Client) CreateIssue(owner, repo string, opt CreateIssueOption) (*Issue, *Response, error) {
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
return nil, nil, err
}
if err := opt.Validate(); err != nil {
return nil, nil, err
}
body, err := json.Marshal(&opt)
if err != nil {
return nil, nil, err
}
issue := new(Issue)
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/issues", owner, repo),
jsonHeader, bytes.NewReader(body), issue)
c.issueBackwardsCompatibility(issue)
return issue, resp, err
}
// EditIssueOption options for editing an issue
type EditIssueOption struct {
Title string `json:"title"`
Body *string `json:"body"`
Ref *string `json:"ref"`
216:type CreateIssueOption struct {
217- Title string `json:"title"`
218- Body string `json:"body"`
219- Ref string `json:"ref"`
220- Assignees []string `json:"assignees"`
221- Deadline *time.Time `json:"due_date"`
222- // milestone id
223- Milestone int64 `json:"milestone"`
224- // list of label ids
225- Labels []int64 `json:"labels"`
226- Closed bool `json:"closed"`
227-}
228-
229-// Validate the CreateIssueOption struct
230-func (opt CreateIssueOption) Validate() error {
231- if len(strings.TrimSpace(opt.Title)) == 0 {
232- return fmt.Errorf("title is empty")
233- }
234- return nil
235-}
236-
237-// CreateIssue create a new issue for a given repository
238-func (c *Client) CreateIssue(owner, repo string, opt CreateIssueOption) (*Issue, *Response, error) {
239- if err := escapeValidatePathSegments(&owner, &repo); err != nil {
240- return nil, nil, err
241- }
242- if err := opt.Validate(); err != nil {
243- return nil, nil, err
244- }
245- body, err := json.Marshal(&opt)
246- if err != nil {
{"path": "internal/agentrun/system.md", "start_line": 1, "end_line": 120}
{"path":"internal/agentrun/system.md","content":"You are an autonomous coding agent working inside `zoo`, a system that\r\ntriggers you from Forgejo (a Gitea-family forge) issue/PR events.\r\n\r\n**Your environment**\r\n\r\n- Your working tree is at `/project`, checked out on a fresh branch off\r\n the repository's default branch. Only `/project` persists; nothing\r\n outside it survives between tool calls.\r\n- The event that triggered you (issue or pull request JSON) is available\r\n at `/event` inside the container, and is also included below.\r\n- You have a real git remote configured with push access. When you're\r\n done, `git add`/`git commit`/`git push` your branch — that's how your\r\n work gets saved. Nothing is persisted automatically.\r\n\r\n**Tools**\r\n\r\n- `bash`, `git`, `read_file`, `write_file`, `list_files`, `grep_search`,\r\n `move_file`, `remove_file` operate on the project container.\r\n- `comment`, `open_pull_request`, `request_review`, `add_label`,\r\n `remove_label`, `close_issue`, `reopen_issue`, `assign_issue` act\r\n directly on the Forgejo issue/PR that triggered this run — use\r\n `comment` to report back to the person who filed it, and\r\n `open_pull_request` once you've pushed a branch with your changes.\r\n\r\nBelow, in \"Your identity\" and \"Other agents\", you'll find your own role\r\n(from your Forgejo profile) and a roster of the other agents zoo runs,\r\neach with their own role. If a task in front of you clearly belongs to\r\nsomeone else's role, use `assign_issue` to hand it off rather than\r\ndoing it yourself.\r\n\r\n**Git**\r\n\r\nYou may use normal git commands to manage your working tree. For remote\r\n(pull, fetch, push, etc.) git operations, you can use the `git` tool.\r\n\r\n**Guidelines**\r\n\r\n- Make surgical changes: modify only what's necessary for the task in\r\n the triggering event.\r\n- Before finishing, verify your changes (re-read the file, run tests or\r\n a build if the project has them).\r\n- Always leave a `comment` summarizing what you did (or why you couldn't\r\n finish), and use `open_pull_request` when you have a change ready for\r\n review. Don't leave the issue without a response.\r","start_line":1,"end_line":44,"total_lines":44,"truncated":false}
{"path": "internal/tools/tools_test.go", "start_line": 1, "end_line": 200}
{"path":"internal/tools/tools_test.go","content":"package tools\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"strings\"\r\n\t\"testing\"\r\n)\r\n\r\ntype fakeForgejoActions struct {\r\n\tcomments []string\r\n\tassigned []string\r\n}\r\n\r\nfunc (f *fakeForgejoActions) Comment(body string) error {\r\n\tf.comments = append(f.comments, body)\r\n\treturn nil\r\n}\r\nfunc (f *fakeForgejoActions) OpenPullRequest(head, base, title, body string) error { return nil }\r\nfunc (f *fakeForgejoActions) RequestReview(reviewers []string) error { return nil }\r\nfunc (f *fakeForgejoActions) AddLabel(name string) error { return nil }\r\nfunc (f *fakeForgejoActions) RemoveLabel(name string) error { return nil }\r\nfunc (f *fakeForgejoActions) CloseIssue() error { return nil }\r\nfunc (f *fakeForgejoActions) ReopenIssue() error { return nil }\r\nfunc (f *fakeForgejoActions) AssignIssue(agentName string) error {\r\n\tf.assigned = append(f.assigned, agentName)\r\n\treturn nil\r\n}\r\n\r\ntype fakeContext struct {\r\n\tlastCmd string\r\n\toutput string\r\n\terr error\r\n\tfg *fakeForgejoActions\r\n\r\n\tlastGitSubcommand string\r\n\tlastGitArgs []string\r\n}\r\n\r\nfunc (f *fakeContext) Exec(ctx context.Context, command string) (string, error) {\r\n\tf.lastCmd = command\r\n\treturn f.output, f.err\r\n}\r\n\r\nfunc (f *fakeContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {\r\n\tf.lastGitSubcommand = subcommand\r\n\tf.lastGitArgs = args\r\n\treturn f.output, f.err\r\n}\r\n\r\nfunc (f *fakeContext) Forgejo() ForgejoActions {\r\n\treturn f.fg\r\n}\r\n\r\nfunc TestShellQuote(t *testing.T) {\r\n\tcases := map[string]string{\r\n\t\t\"simple\": \"'simple'\",\r\n\t\t\"it's a dir\": `'it'\\''s a dir'`,\r\n\t}\r\n\tfor in, want := range cases {\r\n\t\tif got := shellQuote(in); got != want {\r\n\t\t\tt.Errorf(\"shellQuote(%q) = %q, want %q\", in, got, want)\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunc TestReadFileParsesMetaAndContent(t *testing.T) {\r\n\tfc := \u0026fakeContext{output: \"3\\nline one\\nline two\\nline three\\n\"}\r\n\r\n\tout, err := readFile(fc, readFileParams{Path: \"src/main.go\"})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tif !strings.Contains(fc.lastCmd, \"/project/src/main.go\") {\r\n\t\tt.Fatalf(\"expected command to reference /project/src/main.go, got %q\", fc.lastCmd)\r\n\t}\r\n\tif !strings.Contains(out, \"line one\") || !strings.Contains(out, `\"total_lines\":3`) {\r\n\t\tt.Fatalf(\"unexpected result: %s\", out)\r\n\t}\r\n\tif strings.Contains(out, `\"truncated\":true`) {\r\n\t\tt.Fatalf(\"full read should not be truncated: %s\", out)\r\n\t}\r\n}\r\n\r\nfunc TestGitRejectsDisallowedSubcommand(t *testing.T) {\r\n\tfc := \u0026fakeContext{}\r\n\r\n\t_, err := git(fc, gitParams{Subcommand: \"push-force\"})\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for disallowed subcommand\")\r\n\t}\r\n}\r\n\r\nfunc TestGitPassesSubcommandAndArgsThrough(t *testing.T) {\r\n\tfc := \u0026fakeContext{output: \"ok\"}\r\n\r\n\t_, err := git(fc, gitParams{Subcommand: \"commit\", Args: []string{\"-m\", \"fix: handle it's edge case\"}})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tif fc.lastGitSubcommand != \"commit\" {\r\n\t\tt.Fatalf(\"expected subcommand %q, got %q\", \"commit\", fc.lastGitSubcommand)\r\n\t}\r\n\tif len(fc.lastGitArgs) != 2 || fc.lastGitArgs[1] != \"fix: handle it's edge case\" {\r\n\t\tt.Fatalf(\"expected args passed through unescaped, got %v\", fc.lastGitArgs)\r\n\t}\r\n}\r\n\r\nfunc TestCommentToolCallsForgejo(t *testing.T) {\r\n\tfg := \u0026fakeForgejoActions{}\r\n\tfc := \u0026fakeContext{fg: fg}\r\n\r\n\tif _, err := comment(fc, commentParams{Body: \"hello\"}); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tif len(fg.comments) != 1 || fg.comments[0] != \"hello\" {\r\n\t\tt.Fatalf(\"unexpected comments: %v\", fg.comments)\r\n\t}\r\n}\r\n\r\nfunc TestAssignIssueToolCallsForgejo(t *testing.T) {\r\n\tfg := \u0026fakeForgejoActions{}\r\n\tfc := \u0026fakeContext{fg: fg}\r\n\r\n\tout, err := assignIssue(fc, assignIssueParams{Agent: \"greg\"})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif out != \"Assigned to greg\" {\r\n\t\tt.Fatalf(\"unexpected result: %q\", out)\r\n\t}\r\n\tif len(fg.assigned) != 1 || fg.assigned[0] != \"greg\" {\r\n\t\tt.Fatalf(\"unexpected assignments: %v\", fg.assigned)\r\n\t}\r\n}\r\n\r\nfunc TestRegistryRoundTrip(t *testing.T) {\r\n\tif len(All()) == 0 {\r\n\t\tt.Fatal(\"expected registered tools\")\r\n\t}\r\n\r\n\tfound := false\r\n\tfor _, tl := range All() {\r\n\t\tif tl.Function.Name == \"bash\" {\r\n\t\t\tfound = true\r\n\t\t}\r\n\t}\r\n\tif !found {\r\n\t\tt.Fatal(\"expected bash tool to be registered\")\r\n\t}\r\n}\r","start_line":1,"end_line":153,"total_lines":153,"truncated":false}
{"command": "cd /project && grep -n \"type Issue struct\" -A 40 /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue.go | head -50"}
36:type Issue struct {
37- ID int64 `json:"id"`
38- URL string `json:"url"`
39- HTMLURL string `json:"html_url"`
40- Index int64 `json:"number"`
41- Poster *User `json:"user"`
42- OriginalAuthor string `json:"original_author"`
43- OriginalAuthorID int64 `json:"original_author_id"`
44- Title string `json:"title"`
45- Body string `json:"body"`
46- Ref string `json:"ref"`
47- Labels []*Label `json:"labels"`
48- Milestone *Milestone `json:"milestone"`
49- Assignees []*User `json:"assignees"`
50- // Whether the issue is open or closed
51- State StateType `json:"state"`
52- IsLocked bool `json:"is_locked"`
53- Comments int `json:"comments"`
54- Created time.Time `json:"created_at"`
55- Updated time.Time `json:"updated_at"`
56- Closed *time.Time `json:"closed_at"`
57- Deadline *time.Time `json:"due_date"`
58- PullRequest *PullRequestMeta `json:"pull_request"`
59- Repository *RepositoryMeta `json:"repository"`
60-}
61-
62-// ListIssueOption list issue options
63-type ListIssueOption struct {
64- ListOptions
65- State StateType
66- Type IssueType
67- Labels []string
68- Milestones []string
69- KeyWord string
70- Since time.Time
71- Before time.Time
72- // filter by created by username
73- CreatedBy string
74- // filter by assigned to username
75- AssignedBy string
76- // filter by username mentioned
{"query": "ForgejoActions", "context_after": 3}
/project/internal/tools/context.go:28: Forgejo() ForgejoActions
/project/internal/tools/context.go-29-}
/project/internal/tools/context.go-30-
/project/internal/tools/context.go:31:// ForgejoActions are the actions available to an agent against the
/project/internal/tools/context.go-32-// triggering issue/PR. Implemented by internal/agentrun, backed by
/project/internal/tools/context.go-33-// internal/forgejo.Client plus the run's owner/repo/issue.
/project/internal/tools/context.go:34:type ForgejoActions interface {
/project/internal/tools/context.go-35- Comment(body string) error
/project/internal/tools/context.go-36- OpenPullRequest(head, base, title, body string) error
/project/internal/tools/context.go-37- RequestReview(reviewers []string) error
--
/project/internal/tools/tools_test.go:9:type fakeForgejoActions struct {
/project/internal/tools/tools_test.go-10- comments []string
/project/internal/tools/tools_test.go-11- assigned []string
/project/internal/tools/tools_test.go-12-}
--
/project/internal/tools/tools_test.go:14:func (f *fakeForgejoActions) Comment(body string) error {
/project/internal/tools/tools_test.go-15- f.comments = append(f.comments, body)
/project/internal/tools/tools_test.go-16- return nil
/project/internal/tools/tools_test.go-17-}
/project/internal/tools/tools_test.go:18:func (f *fakeForgejoActions) OpenPullRequest(head, base, title, body string) error { return nil }
/project/internal/tools/tools_test.go:19:func (f *fakeForgejoActions) RequestReview(reviewers []string) error { return nil }
/project/internal/tools/tools_test.go:20:func (f *fakeForgejoActions) AddLabel(name string) error { return nil }
/project/internal/tools/tools_test.go:21:func (f *fakeForgejoActions) RemoveLabel(name string) error { return nil }
/project/internal/tools/tools_test.go:22:func (f *fakeForgejoActions) CloseIssue() error { return nil }
/project/internal/tools/tools_test.go:23:func (f *fakeForgejoActions) ReopenIssue() error { return nil }
/project/internal/tools/tools_test.go:24:func (f *fakeForgejoActions) AssignIssue(agentName string) error {
/project/internal/tools/tools_test.go-25- f.assigned = append(f.assigned, agentName)
/project/internal/tools/tools_test.go-26- return nil
/project/internal/tools/tools_test.go-27-}
--
/project/internal/tools/tools_test.go:33: fg *fakeForgejoActions
/project/internal/tools/tools_test.go-34-
/project/internal/tools/tools_test.go-35- lastGitSubcommand string
/project/internal/tools/tools_test.go-36- lastGitArgs []string
--
/project/internal/tools/tools_test.go:50:func (f *fakeContext) Forgejo() ForgejoActions {
/project/internal/tools/tools_test.go-51- return f.fg
/project/internal/tools/tools_test.go-52-}
/project/internal/tools/tools_test.go-53-
--
/project/internal/tools/tools_test.go:111: fg := &fakeForgejoActions{}
/project/internal/tools/tools_test.go-112- fc := &fakeContext{fg: fg}
/project/internal/tools/tools_test.go-113-
/project/internal/tools/tools_test.go-114- if _, err := comment(fc, commentParams{Body: "hello"}); err != nil {
--
/project/internal/tools/tools_test.go:124: fg := &fakeForgejoActions{}
/project/internal/tools/tools_test.go-125- fc := &fakeContext{fg: fg}
/project/internal/tools/tools_test.go-126-
/project/internal/tools/tools_test.go-127- out, err := assignIssue(fc, assignIssueParams{Agent: "greg"})
--
/project/internal/agentrun/forgejo.go:9:// runForgejoActions implements tools.ForgejoActions, binding the
/project/internal/agentrun/forgejo.go-10-// generic forgejo.Client to the single issue/PR that triggered this run
/project/internal/agentrun/forgejo.go-11-// so agent tool calls don't need to specify owner/repo/index themselves.
/project/internal/agentrun/forgejo.go:12:type runForgejoActions struct {
/project/internal/agentrun/forgejo.go-13- client *forgejo.Client
/project/internal/agentrun/forgejo.go-14- owner, repo string
/project/internal/agentrun/forgejo.go-15- index int64
--
/project/internal/agentrun/forgejo.go:22:func (a *runForgejoActions) logResult(action string, err error, args ...any) {
/project/internal/agentrun/forgejo.go-23- fields := append([]any{"owner", a.owner, "repo", a.repo, "index", a.index}, args...)
/project/internal/agentrun/forgejo.go-24-
/project/internal/agentrun/forgejo.go-25- if err != nil {
--
/project/internal/agentrun/forgejo.go:33:func (a *runForgejoActions) Comment(body string) error {
/project/internal/agentrun/forgejo.go-34- err := a.client.CreateIssueComment(a.owner, a.repo, a.index, body)
/project/internal/agentrun/forgejo.go-35- a.logResult("comment", err)
/project/internal/agentrun/forgejo.go-36-
--
/project/internal/agentrun/forgejo.go:40:func (a *runForgejoActions) OpenPullRequest(head, base, title, body string) error {
/project/internal/agentrun/forgejo.go-41- err := a.client.CreatePullRequest(a.owner, a.repo, head, base, title, body)
/project/internal/agentrun/forgejo.go-42- a.logResult("open_pull_request", err, "head", head, "base", base, "title", title)
/project/internal/agentrun/forgejo.go-43-
--
/project/internal/agentrun/forgejo.go:47:func (a *runForgejoActions) RequestReview(reviewers []string) error {
/project/internal/agentrun/forgejo.go-48- err := a.client.RequestReview(a.owner, a.repo, a.index, reviewers)
/project/internal/agentrun/forgejo.go-49- a.logResult("request_review", err, "reviewers", reviewers)
/project/internal/agentrun/forgejo.go-50-
--
/project/internal/agentrun/forgejo.go:54:func (a *runForgejoActions) AddLabel(name string) error {
/project/internal/agentrun/forgejo.go-55- err := a.client.AddLabel(a.owner, a.repo, a.index, name)
/project/internal/agentrun/forgejo.go-56- a.logResult("add_label", err, "label", name)
/project/internal/agentrun/forgejo.go-57-
--
/project/internal/agentrun/forgejo.go:61:func (a *runForgejoActions) RemoveLabel(name string) error {
/project/internal/agentrun/forgejo.go-62- err := a.client.RemoveLabel(a.owner, a.repo, a.index, name)
/project/internal/agentrun/forgejo.go-63- a.logResult("remove_label", err, "label", name)
/project/internal/agentrun/forgejo.go-64-
--
/project/internal/agentrun/forgejo.go:68:func (a *runForgejoActions) CloseIssue() error {
/project/internal/agentrun/forgejo.go-69- err := a.client.CloseIssue(a.owner, a.repo, a.index)
/project/internal/agentrun/forgejo.go-70- a.logResult("close_issue", err)
/project/internal/agentrun/forgejo.go-71-
--
/project/internal/agentrun/forgejo.go:75:func (a *runForgejoActions) ReopenIssue() error {
/project/internal/agentrun/forgejo.go-76- err := a.client.ReopenIssue(a.owner, a.repo, a.index)
/project/internal/agentrun/forgejo.go-77- a.logResult("reopen_issue", err)
/project/internal/agentrun/forgejo.go-78-
--
/project/internal/agentrun/forgejo.go:82:func (a *runForgejoActions) AssignIssue(agentName string) error {
{"command": "cd /project && grep -rn \"NewTool\\|AddStringProperty\\|AddStringArrayProperty\" internal/llm/types.go | head -20"}
119:func NewTool(name, description string) Tool {
146:func (t *Tool) AddStringProperty(name, description string, required bool) *Tool {
170:func (t *Tool) AddStringArrayProperty(name, description string, required bool) *Tool {
{"path": "internal/forgejo/client.go", "find": "// CreatePullRequest opens a PR from head into base.", "replace": "// CreateIssue opens a new issue in the given repository and returns the\n// new issue's number and HTML URL, so callers (e.g. the create_issue\n// tool) can tell the agent where it landed.\nfunc (c *Client) CreateIssue(owner, repo, title, body string) (int64, string, error) {\n\tissue, _, err := c.sdk.CreateIssue(owner, repo, sdk.CreateIssueOption{\n\t\tTitle: title,\n\t\tBody: body,\n\t})\n\tif err != nil {\n\t\treturn 0, \"\", fmt.Errorf(\"create issue %q on %s/%s: %w\", title, owner, repo, err)\n\t}\n\n\treturn issue.Index, issue.HTMLURL, nil\n}\n\n// CreatePullRequest opens a PR from head into base."}
1 occurrences replaced
{"path": "internal/tools/context.go", "find": "\t// AssignIssue hands the triggering issue/PR off to another agent by\n\t// their name (which doubles as their Forgejo username); the\n\t// resulting issue:assigned event triggers that agent's run.\n\tAssignIssue(agentName string) error\n}", "replace": "\t// AssignIssue hands the triggering issue/PR off to another agent by\n\t// their name (which doubles as their Forgejo username); the\n\t// resulting issue:assigned event triggers that agent's run.\n\tAssignIssue(agentName string) error\n\n\t// CreateIssue opens a new issue in the same repository as the\n\t// triggering one, and returns the new issue's number and HTML URL.\n\tCreateIssue(title, body string) (int64, string, error)\n}"}
1 occurrences replaced
{"path": "internal/agentrun/forgejo.go", "find": "func (a *runForgejoActions) AssignIssue(agentName string) error {\n\terr := a.client.AssignIssue(a.owner, a.repo, a.index, agentName)\n\ta.logResult(\"assign_issue\", err, \"assignee\", agentName)\n\n\treturn err\n}", "replace": "func (a *runForgejoActions) AssignIssue(agentName string) error {\n\terr := a.client.AssignIssue(a.owner, a.repo, a.index, agentName)\n\ta.logResult(\"assign_issue\", err, \"assignee\", agentName)\n\n\treturn err\n}\n\nfunc (a *runForgejoActions) CreateIssue(title, body string) (int64, string, error) {\n\tindex, url, err := a.client.CreateIssue(a.owner, a.repo, title, body)\n\ta.logResult(\"create_issue\", err, \"title\", title, \"new_index\", index)\n\n\treturn index, url, err\n}"}
1 occurrences replaced
{"path": "internal/agentrun/forgejo.go", "find": "\ta.logResult(\"create_issue\", err, \"title\", title, \"new_index\", index)", "replace": "\ta.logResult(\"create_issue\", err, \"title\", title, \"new_issue\", index)"}
1 occurrences replaced
{"path": "internal/tools/forgejo.go", "find": "func assignIssue(ctx Context, params assignIssueParams) (string, error) {\n\tif err := ctx.Forgejo().AssignIssue(params.Agent); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn fmt.Sprintf(\"Assigned to %s\", params.Agent), nil\n}", "replace": "func assignIssue(ctx Context, params assignIssueParams) (string, error) {\n\tif err := ctx.Forgejo().AssignIssue(params.Agent); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn fmt.Sprintf(\"Assigned to %s\", params.Agent), nil\n}\n\ntype createIssueParams struct {\n\tTitle string `json:\"title\"`\n\tBody string `json:\"body\"`\n}\n\nfunc init() {\n\ttool := llm.NewTool(\n\t\t\"create_issue\",\n\t\t\"Create a new issue in the same repository as the one that triggered this run. Use it for follow-up work, bugs you discover, or ideas that deserve their own thread.\")\n\n\ttool.AddStringProperty(\"title\", \"The issue title\", true)\n\ttool.AddStringProperty(\"body\", \"The issue description, in Markdown\", false)\n\n\tRegister(tool, createIssue)\n}\n\nfunc createIssue(ctx Context, params createIssueParams) (string, error) {\n\tindex, url, err := ctx.Forgejo().CreateIssue(params.Title, params.Body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn fmt.Sprintf(\"Issue #%d created: %s\", index, url), nil\n}"}
1 occurrences replaced
{"path": "internal/agentrun/system.md", "find": "- `comment`, `open_pull_request`, `request_review`, `add_label`,\n `remove_label`, `close_issue`, `reopen_issue`, `assign_issue` act\n directly on the Forgejo issue/PR that triggered this run — use\n `comment` to report back to the person who filed it, and\n `open_pull_request` once you've pushed a branch with your changes.", "replace": "- `comment`, `open_pull_request`, `request_review`, `add_label`,\n `remove_label`, `close_issue`, `reopen_issue`, `assign_issue`,\n `create_issue` act directly on the Forgejo issue/PR that triggered\n this run (or the repository it lives in, for `create_issue`) — use\n `comment` to report back to the person who filed it, and\n `open_pull_request` once you've pushed a branch with your changes."}
1 occurrences replaced
{"path": "internal/tools/tools_test.go", "find": "type fakeForgejoActions struct {\n\tcomments []string\n\tassigned []string\n}", "replace": "type fakeForgejoActions struct {\n\tcomments []string\n\tassigned []string\n\tissues []createIssueParams\n}"}
1 occurrences replaced
{"path": "internal/tools/tools_test.go", "find": "func (f *fakeForgejoActions) AssignIssue(agentName string) error {\n\tf.assigned = append(f.assigned, agentName)\n\treturn nil\n}", "replace": "func (f *fakeForgejoActions) AssignIssue(agentName string) error {\n\tf.assigned = append(f.assigned, agentName)\n\treturn nil\n}\nfunc (f *fakeForgejoActions) CreateIssue(title, body string) (int64, string, error) {\n\tf.issues = append(f.issues, createIssueParams{Title: title, Body: body})\n\treturn int64(len(f.issues)), \"https://example.com/owner/repo/issues/\" + itoa(len(f.issues)), nil\n}\n\nfunc itoa(n int) string {\n\treturn fmt.Sprintf(\"%d\", n)\n}"}
1 occurrences replaced
{"path": "internal/tools/tools_test.go", "find": "import (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n)", "replace": "import (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n)"}
1 occurrences replaced
{"path": "internal/tools/tools_test.go", "find": "func TestRegistryRoundTrip(t *testing.T) {", "replace": "func TestCreateIssueToolCallsForgejo(t *testing.T) {\n\tfg := &fakeForgejoActions{}\n\tfc := &fakeContext{fg: fg}\n\n\tout, err := createIssue(fc, createIssueParams{Title: \"Follow up\", Body: \"Details\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"Issue #1 created: https://example.com/owner/repo/issues/1\" {\n\t\tt.Fatalf(\"unexpected result: %q\", out)\n\t}\n\tif len(fg.issues) != 1 || fg.issues[0].Title != \"Follow up\" || fg.issues[0].Body != \"Details\" {\n\t\tt.Fatalf(\"unexpected issues: %v\", fg.issues)\n\t}\n}\n\nfunc TestRegistryRoundTrip(t *testing.T) {"}
1 occurrences replaced
{"command": "cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -20"}
go: downloading github.com/docker/docker v28.5.2+incompatible go: downloading github.com/hashicorp/hcl/v2 v2.24.0 go: downloading modernc.org/sqlite v1.56.0 go: downloading github.com/google/uuid v1.6.0 go: downloading github.com/docker/go-connections v0.8.1 go: downloading github.com/docker/go-units v0.5.0 go: downloading github.com/moby/docker-image-spec v1.3.1 go: downloading github.com/opencontainers/image-spec v1.1.1 go: downloading github.com/containerd/errdefs v1.0.0 go: downloading github.com/containerd/errdefs/pkg v0.3.0 go: downloading github.com/distribution/reference v0.6.0 go: downloading github.com/opencontainers/go-digest v1.0.0 go: downloading github.com/pkg/errors v0.9.1 go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 go: downloading go.opentelemetry.io/otel/trace v1.45.0 go: downloading go.opentelemetry.io/otel v1.45.0 go: downloading github.com/42wim/httpsig v1.2.3 go: downloading github.com/go-fed/httpsig v1.1.0 go: downloading github.com/hashicorp/go-version v1.7.0 go: downloading golang.org/x/crypto v0.39.0 go: downloading github.com/felixge/httpsnoop v1.1.0 go: downloading go.opentelemetry.io/otel/metric v1.45.0 go: downloading github.com/agext/levenshtein v1.2.1 go: downloading github.com/apparentlymart/go-textseg/v15 v15.0.0 go: downloading github.com/mitchellh/go-wordwrap v1.0.1 go: downloading github.com/zclconf/go-cty v1.16.3 go: downloading github.com/go-logr/logr v1.4.4 go: downloading golang.org/x/sys v0.47.0 go: downloading github.com/google/go-cmp v0.7.0 go: downloading github.com/go-logr/stdr v1.2.2 go: downloading go.opentelemetry.io/auto/sdk v1.2.1 go: downloading github.com/cespare/xxhash/v2 v2.3.0 go: downloading golang.org/x/text v0.40.0 go: downloading modernc.org/libc v1.74.4 go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading modernc.org/mathutil v1.7.1 go: downloading modernc.org/memory v1.11.0 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec ? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.004s ok github.com/abrander/zoo/internal/config 0.004s ok github.com/abrander/zoo/internal/forgejo 0.004s ok github.com/abrander/zoo/internal/livelog 0.052s ok github.com/abrander/zoo/internal/llm 0.003s ok github.com/abrander/zoo/internal/scheduler 1.982s ok github.com/abrander/zoo/internal/store 1.640s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 0.859s
{"query": "reopen_issue|assign_issue"}
No matches found.
{"query": "reopen_issue"}
/project/internal/tools/forgejo.go:188: "reopen_issue",
/project/internal/agentrun/forgejo.go:77: a.logResult("reopen_issue", err)
/project/internal/agentrun/system.md:20: `remove_label`, `close_issue`, `reopen_issue`, `assign_issue`,
{"query": "create_issue"}
/project/internal/tools/forgejo.go:150: "create_issue",
/project/internal/forgejo/client.go:141:// new issue's number and HTML URL, so callers (e.g. the create_issue
/project/internal/agentrun/forgejo.go:91: a.logResult("create_issue", err, "title", title, "new_issue", index)
/project/internal/agentrun/system.md:21: `create_issue` act directly on the Forgejo issue/PR that triggered
/project/internal/agentrun/system.md:22: this run (or the repository it lives in, for `create_issue`) — use
{"command": "cd /project && git diff && go build ./... && go test ./internal/tools/ ./internal/agentrun/ ./internal/forgejo/"}
diff --git a/internal/agentrun/forgejo.go b/internal/agentrun/forgejo.go
index 795055f..76c10fd 100644
--- a/internal/agentrun/forgejo.go
+++ b/internal/agentrun/forgejo.go
@@ -85,3 +85,10 @@ func (a *runForgejoActions) AssignIssue(agentName string) error {
return err
}
+
+func (a *runForgejoActions) CreateIssue(title, body string) (int64, string, error) {
+ index, url, err := a.client.CreateIssue(a.owner, a.repo, title, body)
+ a.logResult("create_issue", err, "title", title, "new_issue", index)
+
+ return index, url, err
+}
diff --git a/internal/agentrun/system.md b/internal/agentrun/system.md
index 1c8d42f..7b927fb 100644
--- a/internal/agentrun/system.md
+++ b/internal/agentrun/system.md
@@ -17,8 +17,9 @@ triggers you from Forgejo (a Gitea-family forge) issue/PR events.
- `bash`, `git`, `read_file`, `write_file`, `list_files`, `grep_search`,
`move_file`, `remove_file` operate on the project container.
- `comment`, `open_pull_request`, `request_review`, `add_label`,
- `remove_label`, `close_issue`, `reopen_issue`, `assign_issue` act
- directly on the Forgejo issue/PR that triggered this run — use
+ `remove_label`, `close_issue`, `reopen_issue`, `assign_issue`,
+ `create_issue` act directly on the Forgejo issue/PR that triggered
+ this run (or the repository it lives in, for `create_issue`) — use
`comment` to report back to the person who filed it, and
`open_pull_request` once you've pushed a branch with your changes.
diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go
index da03a22..39deb24 100644
--- a/internal/forgejo/client.go
+++ b/internal/forgejo/client.go
@@ -137,6 +137,21 @@ func (c *Client) labelID(owner, repo, name string) (int64, error) {
return created.ID, nil
}
+// CreateIssue opens a new issue in the given repository and returns the
+// new issue's number and HTML URL, so callers (e.g. the create_issue
+// tool) can tell the agent where it landed.
+func (c *Client) CreateIssue(owner, repo, title, body string) (int64, string, error) {
+ issue, _, err := c.sdk.CreateIssue(owner, repo, sdk.CreateIssueOption{
+ Title: title,
+ Body: body,
+ })
+ if err != nil {
+ return 0, "", fmt.Errorf("create issue %q on %s/%s: %w", title, owner, repo, err)
+ }
+
+ return issue.Index, issue.HTMLURL, nil
+}
+
// CreatePullRequest opens a PR from head into base.
func (c *Client) CreatePullRequest(owner, repo, head, base, title, body string) error {
_, _, err := c.sdk.CreatePullRequest(owner, repo, sdk.CreatePullRequestOption{
diff --git a/internal/tools/context.go b/internal/tools/context.go
index e49eba7..5c33bdf 100644
--- a/internal/tools/context.go
+++ b/internal/tools/context.go
@@ -44,4 +44,8 @@ type ForgejoActions interface {
// their name (which doubles as their Forgejo username); the
// resulting issue:assigned event triggers that agent's run.
AssignIssue(agentName string) error
+
+ // CreateIssue opens a new issue in the same repository as the
+ // triggering one, and returns the new issue's number and HTML URL.
+ CreateIssue(title, body string) (int64, string, error)
}
diff --git a/internal/tools/forgejo.go b/internal/tools/forgejo.go
index 330d231..fcf58ca 100644
--- a/internal/tools/forgejo.go
+++ b/internal/tools/forgejo.go
@@ -140,6 +140,31 @@ func assignIssue(ctx Context, params assignIssueParams) (string, error) {
return fmt.Sprintf("Assigned to %s", params.Agent), nil
}
+type createIssueParams struct {
+ Title string `json:"title"`
+ Body string `json:"body"`
+}
+
+func init() {
+ tool := llm.NewTool(
+ "create_issue",
+ "Create a new issue in the same repository as the one that triggered this run. Use it for follow-up work, bugs you discover, or ideas that deserve their own thread.")
+
+ tool.AddStringProperty("title", "The issue title", true)
+ tool.AddStringProperty("body", "The issue description, in Markdown", false)
+
+ Register(tool, createIssue)
+}
+
+func createIssue(ctx Context, params createIssueParams) (string, error) {
+ index, url, err := ctx.Forgejo().CreateIssue(params.Title, params.Body)
+ if err != nil {
+ return "", err
+ }
+
+ return fmt.Sprintf("Issue #%d created: %s", index, url), nil
+}
+
type noParams struct{}
func init() {
diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go
index f407147..b62b476 100644
--- a/internal/tools/tools_test.go
+++ b/internal/tools/tools_test.go
@@ -2,6 +2,7 @@ package tools
import (
"context"
+ "fmt"
"strings"
"testing"
)
@@ -9,6 +10,7 @@ import (
type fakeForgejoActions struct {
comments []string
assigned []string
+ issues []createIssueParams
}
func (f *fakeForgejoActions) Comment(body string) error {
@@ -25,6 +27,14 @@ func (f *fakeForgejoActions) AssignIssue(agentName string) error {
f.assigned = append(f.assigned, agentName)
return nil
}
+func (f *fakeForgejoActions) CreateIssue(title, body string) (int64, string, error) {
+ f.issues = append(f.issues, createIssueParams{Title: title, Body: body})
+ return int64(len(f.issues)), "https://example.com/owner/repo/issues/" + itoa(len(f.issues)), nil
+}
+
+func itoa(n int) string {
+ return fmt.Sprintf("%d", n)
+}
type fakeContext struct {
lastCmd string
@@ -136,6 +146,22 @@ func TestAssignIssueToolCallsForgejo(t *testing.T) {
}
}
+func TestCreateIssueToolCallsForgejo(t *testing.T) {
+ fg := &fakeForgejoActions{}
+ fc := &fakeContext{fg: fg}
+
+ out, err := createIssue(fc, createIssueParams{Title: "Follow up", Body: "Details"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out != "Issue #1 created: https://example.com/owner/repo/issues/1" {
+ t.Fatalf("unexpected result: %q", out)
+ }
+ if len(fg.issues) != 1 || fg.issues[0].Title != "Follow up" || fg.issues[0].Body != "Details" {
+ t.Fatalf("unexpected issues: %v", fg.issues)
+ }
+}
+
func TestRegistryRoundTrip(t *testing.T) {
if len(All()) == 0 {
t.Fatal("expected registered tools")
ok github.com/abrander/zoo/internal/tools (cached)
ok github.com/abrander/zoo/internal/agentrun (cached)
ok github.com/abrander/zoo/internal/forgejo (cached)
{"command": "cd /project && git add -A && git commit -m \"Add create_issue tool so agents can file new issues\n\nAgents can now create new issues in the repository of the run that\ntriggered them, via a new create_issue tool (title + optional body).\nIt flows through the existing ForgejoActions -> forgejo.Client layer,\nso the issue is attributed to the agent's own Forgejo account and the\naction is logged like every other forge action. The tool returns the\nnew issue's number and URL so the agent can reference it.\n\n- forgejo.Client: CreateIssue(owner, repo, title, body)\n- tools.ForgejoActions: CreateIssue(title, body)\n- agentrun: implement on runForgejoActions with logging\n- tools: register create_issue, document in system prompt\n- tools_test: fake + TestCreateIssueToolCallsForgejo\" && git push -u origin issue-28-greg"}
ERROR CALLING TOOL: command failed: command exited with status 128
{"command": "cd /project && git status && git log --oneline -3"}
On branch issue-28-greg nothing to commit, working tree clean 7f0c9dd (HEAD -> issue-28-greg) Add create_issue tool so agents can file new issues 1c33680 (origin/main, origin/HEAD, main) Merge pull request 'Move max_live_agents into the environment block' (#23) from issue-22-mika into main 7cf22ff Move max_live_agents into the environment block
{"subcommand": "push", "args": ["-u", "origin", "issue-28-greg"]}
To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-28-greg -> issue-28-greg branch 'issue-28-greg' set up to track 'origin/issue-28-greg'.
{"head": "issue-28-greg", "base": "main", "title": "Add create_issue tool so agents can file new issues", "body": "Closes #28.\n\nAgents can now create new issues in the repository of the run that triggered them, via a new `create_issue` tool taking a `title` and optional Markdown `body`.\n\nDesign notes:\n\n- **Same-repo only.** The tool files issues in the repo that triggered the run (bound via the existing `ForgejoActions` interface), so agents can't scatter issues across arbitrary repos. This keeps the tool simple and safe, and matches the use cases: follow-up work, bugs discovered mid-task, ideas that deserve their own thread.\n- **Attribution.** It flows through the per-agent `forgejo.Client` (via `As(token)`), so the new issue is authored by the acting agent's own Forgejo account, exactly like comments and PRs.\n- **Observable.** The action is logged by `runForgejoActions.logResult` like every other forge action, and the tool returns `Issue #N created: <url>` so the agent can reference the new issue in its summary comment.\n\nChanges:\n\n- `internal/forgejo/client.go`: `CreateIssue(owner, repo, title, body) (int64, string, error)` wrapping the SDK.\n- `internal/tools/context.go`: `CreateIssue` added to the `ForgejoActions` interface.\n- `internal/agentrun/forgejo.go`: implementation on `runForgejoActions` with logging.\n- `internal/tools/forgejo.go`: `create_issue` tool registration.\n- `internal/agentrun/system.md`: tool list updated to mention `create_issue`.\n- `internal/tools/tools_test.go`: fake extended + `TestCreateIssueToolCallsForgejo`.\n\n`go build ./...`, `go vet ./...`, and `go test ./...` all pass."}
Pull request opened
{"body": "Done — agents can now create new issues. I opened a PR with a `create_issue` tool:\n\n- **What it does:** `create_issue(title, body)` files a new issue in the same repository as the one that triggered the agent's run, and returns `Issue #N created: <url>` so the agent can reference it (e.g. in its closing summary comment).\n- **Design choices:**\n - *Same-repo only* — the tool is bound to the triggering run's repository via the existing `ForgejoActions` layer, so agents can't scatter issues across arbitrary repos. It covers the natural use cases: follow-up work, bugs discovered mid-task, ideas that deserve their own thread.\n - *Attribution* — it goes through the per-agent Forgejo client, so the issue is authored by the acting agent's own account, just like comments and PRs.\n - *Traceable* — logged by `runForgejoActions.logResult` like every other forge action.\n- **Changes:** `forgejo.Client.CreateIssue`, a new method on the `ForgejoActions` interface, the `agentrun` implementation, the tool registration, the system prompt's tool list, and a unit test. `go build`/`vet`/`test` all pass."}
Comment posted