3 Commits

Author SHA1 Message Date
-Puter
88f53f550d Zopu Agent Tool Fixing 2026-07-31 01:31:12 +05:30
-Puter
54a5993274 Minor iOS phone fix 2026-07-31 01:30:59 +05:30
-Puter
def3fc949a doc 2026-07-31 01:30:45 +05:30
11 changed files with 931 additions and 102 deletions

View File

@@ -20,6 +20,8 @@ DAEMON_COMMAND_LEASE_MS=60000
RIVET_ENDPOINT=http://localhost:6420
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
ZOPU_SOURCE_REPOSITORY=/absolute/path/to/zopu-code
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
# Flue persistence adapter
FLUE_DB_TOKEN=replace-with-a-long-random-token

View File

@@ -79,7 +79,7 @@ export const ConversationComposer = ({
</Button>
<textarea
aria-label="Message Zopu"
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#55564e]"
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-base outline-none focus:border-[#55564e]"
disabled={busy}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={(event) => {

312
docs/LOCAL_SETUP.md Normal file
View File

@@ -0,0 +1,312 @@
# Local Setup
This guide runs the active Zopu stack locally, including the browser chat and the AgentOS issue-to-PR path.
## What runs
```text
Browser (React Router/Vite, :5173)
-> Convex Cloud (durable product state, auth, workflows)
-> Flue agent server (:3585)
-> Rivet Engine guard endpoint (:6420)
-> AgentOS registry runner
-> isolated Pi workspace mounted from the local repository
-> Gitea branch and pull request
```
The active stack does not use `repos/`; that directory is archived reference code. In particular, the old standalone server on port `3590` is not part of this setup.
## Requirements
- macOS or Linux
- Bun and Node.js matching the repository toolchain (`packages/agents` requires Node `>=22.18 <23 || >=23.6`)
- pnpm 11
- Git with SSH access to `git.openputer.com`
- [Tea](https://gitea.com/gitea/tea) authenticated to `https://git.openputer.com`
- a Convex account with access to the development deployment
- an OpenAI-completions-compatible model endpoint and API key
Install dependencies from the repository root:
```bash
bun install --frozen-lockfile
```
Confirm Git and Tea access before testing issue or PR tools:
```bash
git ls-remote origin HEAD
tea login list
tea issues list
```
The default Tea login should target `https://git.openputer.com`. Never put credentials in committed files.
## Environment
Create the local environment file:
```bash
cp .env.example .env
```
At minimum, configure these groups.
### Application and Convex
```env
CONVEX_DEPLOYMENT=dev:<deployment-name>
CONVEX_URL=https://<deployment>.convex.cloud
CONVEX_SITE_URL=https://<deployment>.convex.site
SITE_URL=http://localhost:5173
VITE_AUTH_URL=http://localhost:5173
VITE_CONVEX_URL=https://<deployment>.convex.cloud
```
### Flue and model provider
```env
FLUE_DB_TOKEN=<shared-random-token>
FLUE_URL=http://127.0.0.1:3585
AGENT_MODEL_PROVIDER=<provider-name>
AGENT_MODEL_NAME=<model-name>
AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=https://<model-endpoint>/v1
AGENT_MODEL_API_KEY=<model-api-key>
AGENT_MODEL_CONTEXT_WINDOW=<positive-integer>
AGENT_MODEL_MAX_TOKENS=<positive-integer>
```
`FLUE_DB_TOKEN` must match the value stored in the Convex deployment.
### Rivet and AgentOS
```env
RIVET_ENDPOINT=http://127.0.0.1:6420
RIVET_PUBLIC_ENDPOINT=http://127.0.0.1:6420
RIVET_WORKSPACE_TOKEN=<random-string-at-least-32-characters>
ZOPU_SOURCE_REPOSITORY=/absolute/path/to/zopu-code
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
```
Use the same repository checkout for `ZOPU_SOURCE_REPOSITORY`. The harness creates Git worktrees beneath `AGENT_WORKSPACE_ROOT`, copies the source checkout's `.env`, installs dependencies, and mounts the isolated checkout into AgentOS.
Port `6420` is the Rivet Engine guard endpoint used by the application. Port `6421` is an internal engine API-peer port and must not be used as `RIVET_ENDPOINT`.
### Gitea
```env
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=<personal-access-token>
```
`GITEA_TOKEN` is optional for read-only agent startup but required for the complete issue-to-PR flow unless Tea and Git already have sufficient local credentials.
The legacy variables `VITE_FLUE_URL` and `VITE_ZOPU_SERVER_URL` are not used by the active web chat path.
## One-time Convex setup
On a new machine, authenticate and configure the Convex development deployment:
```bash
bun run dev:setup
```
Then configure the deployment-scoped values from `packages/backend`:
```bash
cd packages/backend
bunx convex env set SITE_URL 'http://localhost:5173'
bunx convex env set FLUE_DB_TOKEN '<same value as .env>'
bunx convex env set FLUE_URL 'http://<host-reachable-ip>:3585'
```
Convex runs in the cloud, so `FLUE_URL` must be reachable from the Convex deployment. `127.0.0.1` and `localhost` point at Convex's machine, not your laptop. For the current Mac setup, expose Flue over Tailscale and use the Mac's Tailscale address, for example `http://100.x.y.z:3585`.
`SITE_URL` is deployment-scoped and single-valued. Set it to the exact browser origin currently being tested:
```bash
# Browser opened locally
bunx convex env set SITE_URL 'http://localhost:5173'
# Browser opened from another device over Tailscale
bunx convex env set SITE_URL 'http://<tailscale-ip>:5173'
```
If work execution uses a separate agent URL, set `AGENT_BACKEND_URL`; otherwise it falls back to `FLUE_URL`.
## Start the stack
Run each process in its own terminal. The order matters for the AgentOS path.
### 1. Start Convex development
```bash
bun run dev:server
```
This watches and publishes functions to the configured Convex cloud development deployment.
### 2. Start Rivet Engine
The installed RivetKit 2.3.9 package supplies the platform-specific `rivet-engine` binary. Start it with:
```bash
./node_modules/.pnpm/@rivetkit+engine-cli-*/node_modules/@rivetkit/engine-cli-*/rivet-engine start
```
There must be exactly one local engine listening on `127.0.0.1:6420`. Do not start a second engine, and do not use `npx rivetkit dev`; that command is unavailable in the installed version.
### 3. Start the AgentOS registry runner
```bash
cd packages/agents
bun run runner
```
The runner registers the AgentOS workspace actor with Rivet Engine. Keep it running whenever a coding attempt or issue-to-PR request may execute.
### 4. Start Flue agents
For laptop-only access:
```bash
bun run dev:agents -- --port 3585
```
For Convex callbacks or access from another device, bind Flue to all interfaces:
```bash
bun run dev:tailscale:agents -- --port 3585
```
Always pass port `3585`; the Flue CLI defaults to `3583`, but the current Zopu configuration and Convex environment use `3585`.
If the shell already exports remote Rivet values, shell values override `.env`. Start Flue with explicit local values:
```bash
RIVET_ENDPOINT=http://127.0.0.1:6420 \
RIVET_PUBLIC_ENDPOINT=http://127.0.0.1:6420 \
bun run dev:tailscale:agents -- --port 3585
```
### 5. Start the web application
For laptop-only access:
```bash
bun run dev:web
```
For phone or other Tailscale-device access:
```bash
bun run dev:tailscale:web
```
Open `http://localhost:5173`, or `http://<tailscale-ip>:5173` when using the Tailscale command.
## Verify the setup
Check the listeners:
```bash
curl -I http://127.0.0.1:6420
curl -I http://127.0.0.1:3585
curl -I http://127.0.0.1:5173
```
Any HTTP response confirms the process is reachable; these roots may redirect or return `404` because their functional routes live elsewhere.
Then verify behavior in order:
1. Open the web app and sign in.
2. Send a simple chat message and confirm the response streams back through Convex.
3. Ask Zopu to list open Gitea issues.
4. For the full execution path, send `Create a PR for issue #N` for an open, unassigned test issue.
5. Confirm chat immediately reports that the issue was accepted.
6. Follow the Flue server logs for `[create_pr_for_issue]` and wait for a `completed` line with the PR URL.
7. Confirm the branch and pull request in Gitea.
The PR pipeline is asynchronous: the initial chat turn acknowledges acceptance, while AgentOS implements, commits, pushes, and opens the pull request in the background.
## Request flow
```text
Web sends a conversation mutation to Convex
-> Convex persists the exact message
-> Convex dispatches to FLUE_URL/agents/zopu/<organizationId>
-> Flue runs the Zopu agent and its typed tools
-> responses return to Convex
-> the web observes Convex's reactive projection
For code execution:
-> Flue calls the local AgentOS harness
-> the harness creates an isolated Git worktree
-> a Rivet actor boots an AgentOS VM and mounts the worktree
-> Pi implements the issue and produces a candidate revision
-> the host pushes a unique branch
-> Tea creates a Gitea pull request
```
## Common failures
### Chat stays pending or reports that Flue is unavailable
- Confirm Flue is listening on `3585`.
- Confirm the Convex deployment's `FLUE_URL` uses a host-reachable address, not `localhost`.
- Confirm `FLUE_DB_TOKEN` is identical in `.env` and the Convex deployment.
### Sign-in fails or the browser reports CORS errors
Set Convex `SITE_URL` to the exact browser origin. The shared development deployment trusts only the current single value.
### AgentOS fails while mounting the repository
- Confirm both Rivet endpoints use port `6420`.
- Confirm the engine and `bun run runner` are both running.
- Confirm `ZOPU_SOURCE_REPOSITORY` contains `.git`.
- Confirm `AGENT_WORKSPACE_ROOT` is writable.
- Restart the runner after changing AgentOS registry configuration; Flue hot reload is not enough.
The harness clients require CBOR encoding for host-directory mount descriptors. Do not remove the `encoding: "cbor"` configuration in `packages/agents/src/runtime/agent-os.ts`.
### AgentOS reports an ACP completed-message resource limit
The workspace registry raises `limits.acp.maxCompletedMessageBytes` for long Pi coding runs. Restart the runner so the updated registry configuration is registered with Rivet Engine.
### Flue connects to a remote Rivet deployment unexpectedly
Environment variables exported by the shell override values loaded from `.env`. Start Flue and the runner with explicit `RIVET_ENDPOINT` and `RIVET_PUBLIC_ENDPOINT` values pointing to `127.0.0.1:6420`.
### Gitea issue or PR commands fail
Run:
```bash
tea login list
git ls-remote origin HEAD
tea issues list
```
Confirm the default Tea login, SSH key, repository remote, and token all target `git.openputer.com`.
## Repository checks
After changing source or configuration:
```bash
bunx ultracite check <changed-files>
bun run check-types
bun run check
```
For agent-only changes, run `bun run check-types` from `packages/agents` before the root check.
See also:
- [`TECH.md`](TECH.md) for architecture and ownership boundaries
- [`deployment.md`](deployment.md) for the shared staging deployment
- [Rivet AgentOS quickstart](https://rivet.dev/docs/agent-os/quickstart)
- [Flue local development](https://flue.dev/docs/cli/dev)

View File

@@ -16,10 +16,12 @@ Dense context set for product development and coding agents.
9. evaluation.md — quality measurement and improvement
```
For a runnable development environment, see [`LOCAL_SETUP.md`](LOCAL_SETUP.md).
## Use by role
| Role | Minimum context |
|---|---|
| --- | --- |
| Product/definition agent | agent-context, product, glossary, dev-loop |
| Architecture/design agent | agent-context, tech, dev-loop, current Work Definition |
| Coding agent | agent-context, current Design Packet/Slice, tech sections, repository rules |

View File

@@ -41,57 +41,5 @@
"vite-plus": "0.2.2",
"vitest": "catalog:"
},
"packageManager": "pnpm@11.17.0",
"workspaces": {
"packages": [
"apps/web",
"packages/agents",
"packages/auth",
"packages/backend",
"packages/config",
"packages/env",
"packages/primitives",
"packages/ui"
],
"catalog": {
"@rivet-dev/agentos": "0.2.14",
"@rivet-dev/agentos-core": "0.2.14",
"@effect/platform-bun": "4.0.0-beta.99",
"dotenv": "17.4.2",
"zod": "4.4.3",
"lucide-react": "1.27.0",
"next-themes": "0.4.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"sonner": "2.0.7",
"convex": "1.42.3",
"better-auth": "1.6.15",
"@convex-dev/better-auth": "0.12.5",
"@tanstack/react-form": "1.33.2",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.3.3",
"tailwind-merge": "3.6.0",
"@better-auth/expo": "1.6.15",
"effect": "4.0.0-beta.99",
"typescript": "7.0.2",
"@types/bun": "1.3.14",
"heroui-native": "1.0.6",
"vite": "8.1.5",
"vitest": "4.1.10",
"convex-test": "0.0.54",
"react-native": "0.86.0",
"@types/react": "19.2.17",
"@types/node": "22.20.1",
"hono": "4.12.32",
"valibot": "1.4.2",
"streamdown": "2.5.0",
"@tailwindcss/postcss": "4.3.3",
"@tailwindcss/vite": "4.3.3"
}
},
"overrides": {
"react": "19.2.8",
"react-dom": "19.2.8",
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
}
"packageManager": "pnpm@11.17.0"
}

View File

@@ -10,7 +10,7 @@
"dev": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
"dev:tailscale": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
"run": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run",
"runner": "node --env-file=../../.env src/runner.ts",
"runner": "bun --env-file=../../.env src/runner.ts",
"run:zopu": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run zopu",
"run:work-planner": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run work-planner"
},

View File

@@ -3,6 +3,7 @@ import { defineAgent } from "@flue/runtime";
import { local } from "@flue/runtime/node";
import INSTRUCTIONS from "../prompts/zopu-instructions.md" with { type: "markdown" };
import { createCreatePrForIssueTool } from "../tools/create-pr-for-issue";
import { createSliceOneTools } from "../tools/slice-one";
export {
@@ -23,8 +24,8 @@ export default defineAgent(({ env, id }) => {
"Turns actionable conversation into provenanced Signals and proposed Work.",
instructions: INSTRUCTIONS,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
thinkingLevel: "medium",
sandbox: local({ cwd: ZOPU_CODE_PATH }),
tools: createSliceOneTools(id, env),
thinkingLevel: "high",
tools: [...createSliceOneTools(id, env), createCreatePrForIssueTool(env)],
};
});

View File

@@ -14,17 +14,9 @@ When a user sends a message, follow this decision flow:
2. **Identify project context.** Call list_projects. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
3. **Create a Signal (only when actionable).** When the message is actionable:
a. Call list_signal_evidence to see the exact admitted user messages.
b. Select the message IDs that compose the problem statement.
c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention.
d. Include the projectId when the project is known.
3. **Create a Signal (only when actionable).** When the message is actionable: a. Call list_signal_evidence to see the exact admitted user messages. b. Select the message IDs that compose the problem statement. c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention. d. Include the projectId when the project is known.
4. **Route the Signal.** After creating the Signal:
a. Call list_proposed_work for the project.
b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work.
c. Otherwise call create_work_from_signal.
e. If genuinely uncertain whether to attach or create, ask one focused question.
4. **Route the Signal.** After creating the Signal: a. Call list_proposed_work for the project. b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work. c. Otherwise call create_work_from_signal. e. If genuinely uncertain whether to attach or create, ask one focused question.
5. **Explain the outcome.** Tell the user clearly what happened:
- "Captured [Signal title] and linked it to [Work title]."
@@ -41,41 +33,25 @@ When a user sends a message, follow this decision flow:
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
- Never claim you created a Signal until the tool call returns successfully.
- Do not start implementation, planning, sandboxes, verification, or delivery. Those are explicitly outside Slice 1.
- After creating proposed Work, the system may invoke the private work-planner. Never claim that a Definition, Design, approval, or implementation exists unless Convex reports it.
- Proposed Work is the only Work status you directly create.
- After creating proposed Work, the system may invoke the private work-planner. Never claim that a Definition, Design, approval, or implementation exists unless Convex reports it.
- Proposed Work is the only Work status you directly create.
## Issue resolution with create_pr_for_issue
When a user wants an open Gitea issue implemented and turned into a pull request, call `create_pr_for_issue` with the issue number or URL.
- The tool accepts the request immediately and runs the AgentOS implementation, branch push, and PR creation in the background.
- Tell the user the issue was accepted for background implementation; do not claim the PR exists yet and do not wait for completion in the chat turn.
- Do not retry the tool in a tight loop. Background success and failure are written to the agent server logs with the `[create_pr_for_issue]` prefix.
- Do not run repository mutations yourself; the tool owns the implementation and PR pipeline.
## Repository access
Your shell runs inside the `puter/zopu-code` checkout at `/Users/puter/Workspace/zopu/code`, so you can answer questions about this repository and manage its Gitea issues.
### Read operations (always allowed)
- Explore the repository with read-only Git: `git log`, `git status`, `git diff`, `git branch`, `git show`, and reading files. Use this to ground answers about the codebase.
- List open issues with `tea issues list` (defaults to open issues).
- List closed issues with `tea issues list --state closed`.
- View issue details with `tea issues view <issue-number>`.
- View repository information with `tea repo view`.
### Issue management (full access)
- Create a Gitea issue: `tea issues create --title "<title>" [--description "<details>"]`.
- Edit an existing issue: `tea issues edit <issue-number> --title "<new-title>" [--description "<new-details>"]`.
- Close an issue: `tea issues close <issue-number>`.
- Reopen an issue: `tea issues reopen <issue-number>`.
- Add a comment to an issue: `tea issues comment <issue-number> --body "<comment-text>"`.
### Git write operations (use with caution)
You may perform write operations when they serve the user's goals, but follow these safeguards:
- **Commits:** You may stage changes (`git add`) and create commits (`git commit`) when implementing requested changes. Write clear, descriptive commit messages.
- **Branches:** You may create and switch branches (`git checkout -b <branch-name>`) to isolate work.
- **Push:** Only push after explicit user confirmation. Never force-push (`git push --force`).
- **Merge:** Never merge branches without explicit user approval.
- **Rebase/reset:** Never rebase, reset, or rewrite history without explicit user approval.
- **Destructive operations:** Never run `git clean -fd`, `git reset --hard`, or any operation that discards uncommitted work without user confirmation.
### Security rules
- Create a Gitea issue in this repo with `tea issues create --title "<title>" [--description "<details>"]`.
- Never mutate repository state: do not run `git push`, `git merge`, `git rebase`, `git reset`, `git commit`, `git add`, force operations, or any history rewrite.
- Never close, reopen, or edit existing issues (`tea issues close|reopen|edit`); only list and create.
- Never expose credentials, tokens, or the contents of Tea/Git config files.
- Never log or display sensitive environment variables.

View File

@@ -17,8 +17,14 @@ import { Effect } from "effect";
import { HostRepositoryWorkspace } from "./host-repository";
const MAX_ACP_COMPLETED_MESSAGE_BYTES = 128 * 1024 * 1024;
const piConfig = makePiAgentOsConfig();
const workspace = agentOS<undefined, { token: string }>({
limits: {
acp: {
maxCompletedMessageBytes: MAX_ACP_COMPLETED_MESSAGE_BYTES,
},
},
onBeforeConnect: (_context, params) => {
if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
throw new Error("Unauthorized workspace connection");
@@ -83,6 +89,7 @@ export const executeAgentOsAttempt = async (
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
const client = createClient<typeof runtimeRegistry>({
disableMetadataLookup: true,
encoding: "cbor",
...(endpoint ? { endpoint } : {}),
});
const vm = client.workspace.getOrCreate([input.workspaceKey], {
@@ -247,6 +254,7 @@ export const cancelAgentOsAttempt = async (
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
const client = createClient<typeof runtimeRegistry>({
disableMetadataLookup: true,
encoding: "cbor",
...(endpoint ? { endpoint } : {}),
});
await client.workspace

View File

@@ -0,0 +1,572 @@
import { spawnSync } from "node:child_process";
import type { SpawnSyncOptions } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import path from "node:path";
import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime";
import type { JsonValue } from "@flue/runtime";
import * as v from "valibot";
import { executeAgentOsAttempt } from "../runtime/agent-os";
/**
* Direct Zopu chat tool that converts a Gitea issue into an AgentOS-implemented
* pull request. It owns the entire issue → harness → push → PR pipeline so the
* chat agent never needs (and must not use) shell mutation for this workflow.
*/
// The host checkout the chat agent explores. Tea and Git resolve this repo's
// Gitea connection; all Tea/Git commands run with this fixed cwd.
const TRUSTED_REPO_PATH = "/Users/puter/Workspace/zopu/code";
const REPO_SLUG = "puter/zopu-code";
// Bounded output so a verbose harness/CLI can never blow up a tool result.
const MAX_OUTPUT = 4000;
const MAX_ISSUE_BODY = 8000;
const MAX_CHANGED_FILES = 200;
const COMMAND_TIMEOUT_MS = 60_000;
const PUSH_TIMEOUT_MS = 120_000;
interface CommandResult {
readonly status: number | null;
readonly stdout: string;
readonly stderr: string;
}
interface IssueDetails {
readonly body: string;
readonly title: string;
readonly url: string;
}
interface ToolFailure {
readonly [key: string]: JsonValue;
readonly error: string;
readonly issue: string;
readonly stage: string;
}
interface ToolSuccess {
readonly [key: string]: JsonValue;
readonly baseBranch: string;
readonly changedFileCount: number;
readonly changedFiles: string[];
readonly headBranch: string;
readonly issue: string;
readonly issueNumber: number;
readonly ok: true;
readonly pullRequestUrl: string;
readonly summary: string;
}
interface ToolAccepted {
readonly [key: string]: JsonValue;
readonly accepted: true;
readonly backgroundId: string;
readonly issue: string;
readonly issueNumber: number;
readonly message: string;
readonly ok: true;
}
const cap = (value: string, limit = MAX_OUTPUT): string =>
value.length <= limit ? value : `${value.slice(0, limit)}…[truncated]`;
// Stable prefix for every background pipeline log line so operators can grep
// for issue→PR outcomes independently of chat traffic.
const LOG_PREFIX = "[create_pr_for_issue]";
/** Writes one stable, prefixed server log line for a background outcome. */
const logBackground = (level: "info" | "error", message: string): void => {
const line = `${LOG_PREFIX} ${message}`;
if (level === "error") {
console.error(line);
} else {
console.log(line);
}
};
/** Logs a structured terminal pipeline result under the stable prefix. */
const logPipelineResult = (
backgroundId: string,
result: ToolFailure | ToolSuccess
): void => {
if ("ok" in result && result.ok === true) {
logBackground(
"info",
`background=${backgroundId} completed issue=#${result.issueNumber} stage=pr url=${result.pullRequestUrl} changedFiles=${result.changedFileCount}`
);
return;
}
const detail =
"reason" in result && typeof result.reason === "string"
? ` reason=${result.reason}`
: "";
logBackground(
"error",
`background=${backgroundId} failed issue=${result.issue} issueNumber=${result.issueNumber} stage=${result.stage}: ${result.error}${detail}`
);
};
/** Last-resort logger for an unexpected throw outside the pipeline's catch. */
const logPipelineError = (backgroundId: string, error: unknown): void => {
const message = error instanceof Error ? error.message : String(error);
logBackground("error", `background=${backgroundId} unexpected: ${message}`);
};
/**
* Runs one command as a direct child process. Arguments are passed to the
* child verbatim — never through a shell — so issue titles, bodies, and
* revisions cannot inject shell metacharacters.
*/
const run = (
command: string,
args: readonly string[],
options: {
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
} = {}
): CommandResult => {
const spawnOptions: SpawnSyncOptions = {
cwd: options.cwd ?? TRUSTED_REPO_PATH,
env: { ...process.env, ...options.env },
maxBuffer: 1024 * 1024,
timeout: options.timeoutMs ?? COMMAND_TIMEOUT_MS,
};
const result = spawnSync(command, args, spawnOptions);
return {
status: result.status,
stderr: cap((result.stderr ?? "").toString()),
stdout: cap((result.stdout ?? "").toString()),
};
};
/** Extracts the issue number from an issue number, "#23", or a full issue URL. */
const parseIssueNumber = (input: string): number | null => {
// Prefer a /issues/<n> URL path segment so anchors like #comment-45 do not
// hijack the number.
const pathMatch = input.match(/\/issues\/(?<issueNumber>\d+)(?:[^/\d]|$)/iu);
if (pathMatch?.groups?.issueNumber) {
return Math.trunc(Number(pathMatch.groups.issueNumber));
}
const hashMatch = input.match(/#(?<issueNumber>\d+)\b/u);
if (hashMatch?.groups?.issueNumber) {
return Math.trunc(Number(hashMatch.groups.issueNumber));
}
const standalone = input.trim().match(/^\d+$/u);
if (standalone) {
return Math.trunc(Number(standalone[0]));
}
// Last resort: the trailing integer anywhere in the string.
const value = Math.trunc(Number(input.match(/\d+/gu)?.at(-1)));
if (Number.isFinite(value) && value > 0) {
return value;
}
return null;
};
const readIssue = (issueNumber: number): IssueDetails | ToolFailure => {
const result = run("tea", [
"issues",
String(issueNumber),
"--fields",
"index,title,body,url,state",
"--output",
"json",
]);
if (result.status !== 0) {
return {
error: `tea could not read issue #${issueNumber}.`,
issue: String(issueNumber),
issueNumber,
stage: "read-issue",
stderr: result.stderr,
};
}
let data: {
body?: string;
state?: string;
title?: string;
url?: string;
};
try {
data = JSON.parse(result.stdout) as typeof data;
} catch (error) {
return {
error: `Could not parse the issue payload from tea: ${
error instanceof Error ? error.message : String(error)
}`,
issue: String(issueNumber),
issueNumber,
stage: "read-issue",
stderr: result.stdout,
};
}
const title = (data.title ?? "").trim();
if (title.length === 0) {
return {
error: `Issue #${issueNumber} has no title.`,
issue: String(issueNumber),
issueNumber,
stage: "read-issue",
};
}
return {
body: (data.body ?? "").trim(),
title,
url: (data.url ?? "").trim(),
};
};
const buildPrompt = (issueNumber: number, issue: IssueDetails): string => {
const body =
issue.body.length > MAX_ISSUE_BODY
? `${issue.body.slice(0, MAX_ISSUE_BODY)}…[issue body truncated]`
: issue.body;
return [
`Resolve Gitea issue #${issueNumber} in this repository and ship a complete implementation.`,
"",
`## Issue #${issueNumber}: ${issue.title}`,
"",
body || "_(The issue provided no additional description.)_",
"",
"## Instructions",
"- Read AGENTS.md and the relevant product/architecture specifications before changing code.",
"- Implement exactly the change described by the issue with focused, correct edits.",
"- Reuse existing conventions; do not introduce unrelated scope.",
"- Run focused verification for your change before finishing.",
"- Do NOT push, open a pull request, rewrite history, or modify the read-only base checkout.",
" The orchestrator collects your working-tree changes, pushes a candidate branch, and opens the pull request after you finish.",
].join("\n");
};
/** Mirrors HostRepositoryWorkspace's worktree layout to locate the harness checkout. */
const harnessCheckoutPath = (attemptId: string): string => {
const root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces";
const identity = createHash("sha256")
.update(attemptId)
.digest("hex")
.slice(0, 24);
return path.join(root, identity, "repository");
};
const detectBaseBranch = (): string => {
const result = run("git", ["rev-parse", "--abbrev-ref", "origin/HEAD"], {
cwd: TRUSTED_REPO_PATH,
});
if (result.status === 0) {
const ref = result.stdout.trim();
const branch = ref.startsWith("origin/")
? ref.slice("origin/".length)
: ref;
if (branch.length > 0) {
return branch;
}
}
return "master";
};
const pushCandidate = (
checkoutPath: string,
candidateRevision: string,
pushBranch: string
): true | ToolFailure => {
const result = run(
"git",
[
"-C",
checkoutPath,
"push",
"origin",
`${candidateRevision}:refs/heads/${pushBranch}`,
],
{ cwd: checkoutPath, timeoutMs: PUSH_TIMEOUT_MS }
);
if (result.status !== 0) {
return {
error: `Could not push candidate branch "${pushBranch}" from the harness worktree.`,
issue: "",
stage: "push",
stderr: result.stderr,
};
}
return true;
};
const findPullUrl = (head: string): string => {
const result = run("tea", [
"pulls",
"list",
"--state",
"open",
"--fields",
"index,title,head,url",
"--output",
"json",
]);
if (result.status !== 0) {
return "";
}
try {
const pulls = JSON.parse(result.stdout) as {
head?: string;
url?: string;
}[];
const match = pulls.find(
(pull) => pull.head === head || (pull.head ?? "").endsWith(`:${head}`)
);
return (match?.url ?? "").trim();
} catch {
return "";
}
};
const createPullRequest = (
issueNumber: number,
title: string,
head: string,
base: string
): ToolFailure | { url: string } => {
const prTitle = `Fix #${issueNumber}: ${title}`;
const result = run("tea", [
"pulls",
"create",
"--head",
head,
"--base",
base,
"--title",
prTitle,
"--description",
`Fixes #${issueNumber}`,
]);
if (result.status !== 0) {
return {
error: `tea could not create the pull request for branch "${head}".`,
issue: "",
stage: "create-pr",
stderr: result.stderr,
stdout: result.stdout,
};
}
const combined = `${result.stdout}\n${result.stderr}`;
const urlMatch = combined.match(/https?:\/\/[^\s)]+\/pulls\/\d+/u);
if (urlMatch) {
return { url: urlMatch[0] };
}
// Fall back to listing open pulls and matching the head branch.
const fallback = findPullUrl(head);
if (fallback.length > 0) {
return { url: fallback };
}
return {
error:
"The pull request was created, but its URL could not be captured from tea. Inspect open pulls in Gitea.",
issue: "",
stage: "create-pr",
stdout: result.stdout,
};
};
/**
* Runs the full issue → AgentOS → push → PR pipeline and returns a structured
* terminal result. Used both by the background scheduler (chat path) and any
* direct/internal caller that wants the structured outcome. Never rejects: every
* failure is caught and returned as a {@link ToolFailure}.
*/
const runIssuePrPipeline = async (params: {
readonly giteaUrl: string;
readonly giteaToken: string | undefined;
readonly issue: string;
readonly issueNumber: number;
readonly identifiers: {
readonly attemptId: string;
readonly headBranch: string;
readonly runId: string;
readonly workspaceKey: string;
};
}): Promise<ToolFailure | ToolSuccess> => {
const { giteaUrl, giteaToken, issue, issueNumber, identifiers } = params;
try {
const issueOrFailure = readIssue(issueNumber);
if ("error" in issueOrFailure) {
return issueOrFailure;
}
const issueDetails = issueOrFailure;
const baseBranch = detectBaseBranch();
const prompt = buildPrompt(issueNumber, issueDetails);
// The fixed-checkout harness path ignores `auth`/`repositoryUrl` for
// cloning (it always uses the mounted Zopu source worktree); they are
// still supplied as valid schema values. GITEA_TOKEN is optional.
const result = await executeAgentOsAttempt({
attemptId: identifiers.attemptId,
auth: {
credential: giteaToken ?? "unused-by-fixed-checkout",
provider: "gitea",
serverUrl: giteaUrl,
},
baseBranch,
prompt,
repositoryUrl: `${giteaUrl}/${REPO_SLUG}`,
runId: identifiers.runId,
workId: `issue-${issueNumber}`,
workspaceKey: identifiers.workspaceKey,
});
const changedFiles = result.changedFiles.slice(0, MAX_CHANGED_FILES);
if (
result.changedFiles.length === 0 ||
result.candidateRevision === result.baseRevision
) {
return {
error:
"The AgentOS session completed without producing any repository changes, so there is nothing to turn into a pull request.",
issue,
issueNumber,
stage: "harness",
summary: result.summary,
};
}
const checkoutPath = harnessCheckoutPath(identifiers.attemptId);
const pushed = pushCandidate(
checkoutPath,
result.candidateRevision,
identifiers.headBranch
);
if (pushed !== true) {
return {
...pushed,
attemptId: identifiers.attemptId,
baseRevision: result.baseRevision,
candidateRevision: result.candidateRevision,
headBranch: identifiers.headBranch,
issue,
issueNumber,
workspaceKey: identifiers.workspaceKey,
};
}
const pr = createPullRequest(
issueNumber,
issueDetails.title,
identifiers.headBranch,
baseBranch
);
if ("error" in pr) {
return {
...pr,
attemptId: identifiers.attemptId,
baseRevision: result.baseRevision,
candidateRevision: result.candidateRevision,
headBranch: identifiers.headBranch,
issue,
issueNumber,
workspaceKey: identifiers.workspaceKey,
};
}
return {
baseBranch,
changedFileCount: result.changedFiles.length,
changedFiles,
headBranch: identifiers.headBranch,
issue,
issueNumber,
ok: true,
pullRequestUrl: pr.url,
summary: result.summary,
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Unexpected failure";
const reason =
error !== null &&
typeof error === "object" &&
"reason" in error &&
typeof (error as { reason: unknown }).reason === "string"
? (error as { reason: string }).reason
: undefined;
return {
error: message,
issue,
issueNumber,
...(reason ? { reason } : {}),
stage: "harness",
};
}
};
export const createCreatePrForIssueTool = (
runtimeEnv: Record<string, string | undefined>
) => {
const agentEnv = parseAgentEnv(runtimeEnv);
const giteaUrl = agentEnv.GITEA_URL;
return defineTool({
description:
'Validate a Gitea issue number or URL immediately, then return an accepted acknowledgement right away while an AgentOS coding session implements the issue, pushes a candidate branch, and opens a PR titled "Fix #N: <title>" with body "Fixes #N" in the background. Pass a Gitea issue number or full issue URL. The created PR URL and any failure are logged to the server log with the "[create_pr_for_issue]" prefix rather than returned to chat; malformed input returns a parse failure immediately.',
input: v.object({
issue: v.pipe(
v.string(),
v.description(
"A Gitea issue number like 23, or a full issue URL such as https://git.openputer.com/puter/zopu-code/issues/23."
)
),
}),
name: "create_pr_for_issue",
run({ input }): ToolFailure | ToolAccepted {
const issueRef = input.issue.trim();
const issueNumber = parseIssueNumber(issueRef);
if (issueNumber === null) {
return {
error: `Could not parse an issue number from "${input.issue}". Provide a Gitea issue number or URL.`,
issue: input.issue,
stage: "parse",
};
}
// Background identifiers are generated up front so the accepted
// response can reference them and the pipeline can reuse them.
const backgroundId = randomUUID();
const attemptId = `create-pr-${issueNumber}-${randomUUID()}`;
const workspaceKey = `issue-${issueNumber}-pr-${randomUUID()}`;
const runId = `issue-pr-${randomUUID()}`;
const shortId = randomUUID().slice(0, 8);
const headBranch = `zopu/issue-${issueNumber}-${shortId}`;
// Schedule the full issue → AgentOS → push → PR pipeline on the next
// microtask boundary so this turn returns immediately. The pipeline
// catches its own failures and logs a stable prefixed line; the IIFE
// never rejects, so the originating chat turn cannot fail.
queueMicrotask(() => {
void (async () => {
try {
const result = await runIssuePrPipeline({
giteaToken: runtimeEnv.GITEA_TOKEN,
giteaUrl,
identifiers: { attemptId, headBranch, runId, workspaceKey },
issue: input.issue,
issueNumber,
});
logPipelineResult(backgroundId, result);
} catch (error) {
logPipelineError(backgroundId, error);
}
})();
});
return {
accepted: true,
backgroundId,
issue: input.issue,
issueNumber,
message: `Issue #${issueNumber} accepted. An AgentOS session will implement it, push branch "${headBranch}", and open a pull request in the background. The created PR URL and any failure are logged with the "${LOG_PREFIX}" prefix.`,
ok: true,
};
},
});
};

View File

@@ -1,6 +1,14 @@
{
"extends": "@code/config/tsconfig.base.json",
"compilerOptions": { "allowImportingTsExtensions": true, "types": ["bun"] },
"include": ["src/**/*.ts"],
"exclude": ["dist"]
"compilerOptions": {
"types": [
"bun"
]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"dist"
]
}