Zopu Agent Tool Fixing
This commit is contained in:
@@ -20,6 +20,8 @@ DAEMON_COMMAND_LEASE_MS=60000
|
|||||||
RIVET_ENDPOINT=http://localhost:6420
|
RIVET_ENDPOINT=http://localhost:6420
|
||||||
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
|
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
|
||||||
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
|
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 persistence adapter
|
||||||
FLUE_DB_TOKEN=replace-with-a-long-random-token
|
FLUE_DB_TOKEN=replace-with-a-long-random-token
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"dev": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
|
"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",
|
"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",
|
"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: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"
|
"run:work-planner": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run work-planner"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { defineAgent } from "@flue/runtime";
|
|||||||
import { local } from "@flue/runtime/node";
|
import { local } from "@flue/runtime/node";
|
||||||
|
|
||||||
import INSTRUCTIONS from "../prompts/zopu-instructions.md" with { type: "markdown" };
|
import INSTRUCTIONS from "../prompts/zopu-instructions.md" with { type: "markdown" };
|
||||||
|
import { createCreatePrForIssueTool } from "../tools/create-pr-for-issue";
|
||||||
import { createSliceOneTools } from "../tools/slice-one";
|
import { createSliceOneTools } from "../tools/slice-one";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -23,8 +24,8 @@ export default defineAgent(({ env, id }) => {
|
|||||||
"Turns actionable conversation into provenanced Signals and proposed Work.",
|
"Turns actionable conversation into provenanced Signals and proposed Work.",
|
||||||
instructions: INSTRUCTIONS,
|
instructions: INSTRUCTIONS,
|
||||||
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
||||||
thinkingLevel: "medium",
|
|
||||||
sandbox: local({ cwd: ZOPU_CODE_PATH }),
|
sandbox: local({ cwd: ZOPU_CODE_PATH }),
|
||||||
tools: createSliceOneTools(id, env),
|
thinkingLevel: "high",
|
||||||
|
tools: [...createSliceOneTools(id, env), createCreatePrForIssueTool(env)],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
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:
|
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.
|
||||||
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:
|
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.
|
||||||
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:
|
5. **Explain the outcome.** Tell the user clearly what happened:
|
||||||
- "Captured [Signal title] and linked it to [Work title]."
|
- "Captured [Signal title] and linked it to [Work title]."
|
||||||
@@ -41,8 +33,17 @@ 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## Repository access
|
||||||
|
|
||||||
@@ -53,4 +54,4 @@ Your shell runs inside the `puter/zopu-code` checkout at `/Users/puter/Workspace
|
|||||||
- Create a Gitea issue in this repo with `tea issues create --title "<title>" [--description "<details>"]`.
|
- 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 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 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 expose credentials, tokens, or the contents of Tea/Git config files.
|
||||||
|
|||||||
@@ -17,8 +17,14 @@ import { Effect } from "effect";
|
|||||||
|
|
||||||
import { HostRepositoryWorkspace } from "./host-repository";
|
import { HostRepositoryWorkspace } from "./host-repository";
|
||||||
|
|
||||||
|
const MAX_ACP_COMPLETED_MESSAGE_BYTES = 128 * 1024 * 1024;
|
||||||
const piConfig = makePiAgentOsConfig();
|
const piConfig = makePiAgentOsConfig();
|
||||||
const workspace = agentOS<undefined, { token: string }>({
|
const workspace = agentOS<undefined, { token: string }>({
|
||||||
|
limits: {
|
||||||
|
acp: {
|
||||||
|
maxCompletedMessageBytes: MAX_ACP_COMPLETED_MESSAGE_BYTES,
|
||||||
|
},
|
||||||
|
},
|
||||||
onBeforeConnect: (_context, params) => {
|
onBeforeConnect: (_context, params) => {
|
||||||
if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
|
if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
|
||||||
throw new Error("Unauthorized workspace connection");
|
throw new Error("Unauthorized workspace connection");
|
||||||
@@ -83,6 +89,7 @@ export const executeAgentOsAttempt = async (
|
|||||||
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
|
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
|
||||||
const client = createClient<typeof runtimeRegistry>({
|
const client = createClient<typeof runtimeRegistry>({
|
||||||
disableMetadataLookup: true,
|
disableMetadataLookup: true,
|
||||||
|
encoding: "cbor",
|
||||||
...(endpoint ? { endpoint } : {}),
|
...(endpoint ? { endpoint } : {}),
|
||||||
});
|
});
|
||||||
const vm = client.workspace.getOrCreate([input.workspaceKey], {
|
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 endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
|
||||||
const client = createClient<typeof runtimeRegistry>({
|
const client = createClient<typeof runtimeRegistry>({
|
||||||
disableMetadataLookup: true,
|
disableMetadataLookup: true,
|
||||||
|
encoding: "cbor",
|
||||||
...(endpoint ? { endpoint } : {}),
|
...(endpoint ? { endpoint } : {}),
|
||||||
});
|
});
|
||||||
await client.workspace
|
await client.workspace
|
||||||
|
|||||||
572
packages/agents/src/tools/create-pr-for-issue.ts
Normal file
572
packages/agents/src/tools/create-pr-for-issue.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
{
|
{
|
||||||
"extends": "@code/config/tsconfig.base.json",
|
"extends": "@code/config/tsconfig.base.json",
|
||||||
"compilerOptions": { "allowImportingTsExtensions": true, "types": ["bun"] },
|
"compilerOptions": {
|
||||||
"include": ["src/**/*.ts"],
|
"types": [
|
||||||
"exclude": ["dist"]
|
"bun"
|
||||||
}
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user