Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/agent/src/adapters/local-tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry";
import { signedCommitTool } from "./tools/signed-commit";
import { signedRewriteTool } from "./tools/signed-rewrite";

export {
LOCAL_TOOLS_MCP_NAME,
Expand All @@ -11,7 +12,7 @@ export {
} from "./registry";

/** Every tool the general local MCP server can expose. Add new tools here. */
export const LOCAL_TOOLS: LocalTool[] = [signedCommitTool];
export const LOCAL_TOOLS: LocalTool[] = [signedCommitTool, signedRewriteTool];

/** Tools whose gate passes for the given context — the set to actually expose. */
export function enabledLocalTools(
Expand Down
41 changes: 3 additions & 38 deletions packages/agent/src/adapters/local-tools/tools/signed-commit.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,14 @@
import * as path from "node:path";
import { isCloudRun } from "../../../utils/common";
import { resolveGithubToken } from "../../../utils/github-token";
import {
runSignedCommitTool,
SIGNED_COMMIT_TOOL_DESCRIPTION,
SIGNED_COMMIT_TOOL_NAME,
signedCommitToolSchema,
} from "../../signed-commit-shared";
import { defineLocalTool } from "../registry";
import { defineSignedGitTool } from "./signed-git-tool";

/**
* `git_signed_commit` as a local tool. Cloud-run only; the token is resolved
* lazily at call time so the tool stays visible even when the GitHub token
* lands in `process.env` after the session was created (e.g. an orchestrator
* injecting it post-spawn). Committing is core to cloud tasks, so keep it
* exposed past ToolSearch via `alwaysLoad`.
*/
export const signedCommitTool = defineLocalTool({
export const signedCommitTool = defineSignedGitTool({
name: SIGNED_COMMIT_TOOL_NAME,
description: SIGNED_COMMIT_TOOL_DESCRIPTION,
schema: signedCommitToolSchema,
alwaysLoad: true,
isEnabled: (_ctx, meta) => isCloudRun(meta),
handler: (ctx, args) => {
// Prefer a freshly-resolved token (reads the live agentsh env file) over
// the one captured at session setup, so a mid-session credential refresh
// takes effect without rebuilding the session.
const token = resolveGithubToken() ?? ctx.token;
if (!token) {
return Promise.resolve({
content: [
{
type: "text" as const,
text: `${SIGNED_COMMIT_TOOL_NAME} failed: no GitHub token in env (GH_TOKEN/GITHUB_TOKEN)`,
},
],
isError: true,
});
}
// Resolve an explicit `cwd` arg against the session cwd so the agent can
// commit from any clone reachable in the sandbox, not just the one the
// session was rooted at. Absolute paths fall through `path.resolve`
// unchanged; relative paths join the session cwd.
const { cwd: argCwd, ...input } = args;
const cwd = argCwd ? path.resolve(ctx.cwd, argCwd) : ctx.cwd;
return runSignedCommitTool({ cwd, token, taskId: ctx.taskId }, input);
},
run: runSignedCommitTool,
});
48 changes: 48 additions & 0 deletions packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as path from "node:path";
import type { SignedCommitCtx } from "@posthog/git/signed-commit";
import type { z } from "zod";
import { isCloudRun } from "../../../utils/common";
import { resolveGithubToken } from "../../../utils/github-token";
import type { SignedCommitToolResult } from "../../signed-commit-shared";
import { defineLocalTool, type LocalTool } from "../registry";

/**
* Factory for the cloud-only signed-git tools (git_signed_commit / git_signed_rewrite).
* Resolves the token lazily (live /tmp/agent-env first, so a mid-session credential
* refresh takes effect) and the optional `cwd` arg against the session cwd, then
* delegates to the tool's `run`. Kept past ToolSearch via alwaysLoad.
*/
export function defineSignedGitTool<S extends z.ZodRawShape, R>(opts: {
name: string;
description: string;
schema: S;
run: (ctx: SignedCommitCtx, input: R) => Promise<SignedCommitToolResult>;
}): LocalTool {
return defineLocalTool({
name: opts.name,
description: opts.description,
schema: opts.schema,
alwaysLoad: true,
isEnabled: (_ctx, meta) => isCloudRun(meta),
handler: (ctx, args) => {
const token = resolveGithubToken() ?? ctx.token;
if (!token) {
return Promise.resolve({
content: [
{
type: "text" as const,
text: `${opts.name} failed: no GitHub token in env (GH_TOKEN/GITHUB_TOKEN)`,
},
],
isError: true as const,
});
}
const { cwd: argCwd, ...input } = args as { cwd?: string } & Record<
string,
unknown
>;
const cwd = argCwd ? path.resolve(ctx.cwd, argCwd) : ctx.cwd;
return opts.run({ cwd, token, taskId: ctx.taskId }, input as R);
},
});
}
14 changes: 14 additions & 0 deletions packages/agent/src/adapters/local-tools/tools/signed-rewrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {
runSignedRewriteTool,
SIGNED_REWRITE_TOOL_DESCRIPTION,
SIGNED_REWRITE_TOOL_NAME,
signedRewriteToolSchema,
} from "../../signed-commit-shared";
import { defineSignedGitTool } from "./signed-git-tool";

export const signedRewriteTool = defineSignedGitTool({
name: SIGNED_REWRITE_TOOL_NAME,
description: SIGNED_REWRITE_TOOL_DESCRIPTION,
schema: signedRewriteToolSchema,
run: runSignedRewriteTool,
});
88 changes: 70 additions & 18 deletions packages/agent/src/adapters/signed-commit-shared.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
createSignedCommit,
createSignedRewrite,
type SignedCommitCtx,
type SignedCommitInput,
type SignedCommitResult,
type SignedRewriteInput,
} from "@posthog/git/signed-commit";
import { z } from "zod";
import { qualifiedLocalToolName } from "./local-tools/registry";
Expand Down Expand Up @@ -51,10 +53,38 @@ export const signedCommitToolSchema = {
),
};

export function formatSignedCommitResult(result: SignedCommitResult): string {
const list = result.commits.map((c) => `- ${c.sha} ${c.url}`).join("\n");
return `Created ${result.commits.length} signed commit(s) on ${result.branch}:\n${list}`;
}
export const SIGNED_REWRITE_TOOL_NAME = "git_signed_rewrite";
export const SIGNED_REWRITE_QUALIFIED_TOOL_NAME = qualifiedLocalToolName(
SIGNED_REWRITE_TOOL_NAME,
);

export const SIGNED_REWRITE_TOOL_DESCRIPTION =
"Force-update a branch with GitHub-signed (Verified) history, the signed-commit equivalent " +
"of `git push --force`. First rebase/merge locally with normal `git` (resolving conflicts and " +
"finishing with `git rebase --continue`, NOT `git commit`); then call this to republish the " +
"branch's commits as Verified and atomically move the remote branch onto them. Use this to " +
"update an existing PR after a rebase or conflict fix. Rewrites the current branch by default.";

export const signedRewriteToolSchema = {
branch: z
.string()
.optional()
.describe("Branch to rewrite; defaults to the current branch."),
onto: z
.string()
.optional()
.describe(
"Commit/ref the rewritten history sits on (e.g. origin/master). " +
"Defaults to the merge-base of the current branch with the repo's default branch.",
),
cwd: z
.string()
.optional()
.describe(
"Path to the git checkout to rewrite; defaults to the session's working directory. " +
"Relative paths resolve against the session cwd.",
),
};

export interface SignedCommitToolResult {
content: { type: "text"; text: string }[];
Expand All @@ -64,27 +94,49 @@ export interface SignedCommitToolResult {
[key: string]: unknown;
}

/**
* Runs `git_signed_commit` and formats the MCP result. Shared by the Claude
* in-process tool and the Codex stdio server so success/error formatting (and
* the error-message prefix) can't drift between adapters.
*/
export async function runSignedCommitTool(
async function runSignedTool<A>(
toolName: string,
op: (ctx: SignedCommitCtx, args: A) => Promise<SignedCommitResult>,
lead: (result: SignedCommitResult) => string,
ctx: SignedCommitCtx,
args: SignedCommitInput,
args: A,
): Promise<SignedCommitToolResult> {
try {
const result = await createSignedCommit(ctx, args);
return {
content: [{ type: "text", text: formatSignedCommitResult(result) }],
};
const result = await op(ctx, args);
const list = result.commits.map((c) => `- ${c.sha} ${c.url}`).join("\n");
return { content: [{ type: "text", text: `${lead(result)}:\n${list}` }] };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [
{ type: "text", text: `${SIGNED_COMMIT_TOOL_NAME} failed: ${message}` },
],
content: [{ type: "text", text: `${toolName} failed: ${message}` }],
isError: true,
};
}
}

export function runSignedCommitTool(
ctx: SignedCommitCtx,
args: SignedCommitInput,
): Promise<SignedCommitToolResult> {
return runSignedTool(
SIGNED_COMMIT_TOOL_NAME,
createSignedCommit,
(r) => `Created ${r.commits.length} signed commit(s) on ${r.branch}`,
ctx,
args,
);
}

export function runSignedRewriteTool(
ctx: SignedCommitCtx,
args: SignedRewriteInput,
): Promise<SignedCommitToolResult> {
return runSignedTool(
SIGNED_REWRITE_TOOL_NAME,
createSignedRewrite,
(r) =>
`Force-updated ${r.branch} with ${r.commits.length} signed commit(s)`,
ctx,
args,
);
}
13 changes: 12 additions & 1 deletion packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import {
type AgentErrorClassification,
classifyAgentError,
} from "../adapters/error-classification";
import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../adapters/signed-commit-shared";
import {
SIGNED_COMMIT_QUALIFIED_TOOL_NAME,
SIGNED_REWRITE_QUALIFIED_TOOL_NAME,
} from "../adapters/signed-commit-shared";
import type { PermissionMode } from "../execution-mode";
import { DEFAULT_CODEX_MODEL } from "../gateway-models";
import { HandoffCheckpointTracker } from "../handoff-checkpoint";
Expand Down Expand Up @@ -1699,6 +1702,13 @@ It creates a GitHub-signed ("Verified") commit on the branch and keeps your loca
sync. To start a new branch, pass \`branch\` (prefixed with \`posthog-code/\`) — the tool creates
it on the remote for you.

## Rewriting / force-pushing (rebases, conflict fixes)
\`git push --force\` is also blocked. To update a branch after a local rebase or conflict
resolution, rebase/merge locally with normal \`git\` (resolve conflicts and finish with
\`git rebase --continue\`, NOT \`git commit\`), then call the \`git_signed_rewrite\` tool (full
name \`${SIGNED_REWRITE_QUALIFIED_TOOL_NAME}\`). It republishes the branch's commits as Verified
and atomically force-updates the remote branch. This is how you fix conflicts on an existing PR.

## Attribution
Do NOT add "Co-Authored-By" trailers or "Generated with [Claude Code]" lines to your
commit messages. The \`git_signed_commit\` tool automatically appends the only trailers
Expand Down Expand Up @@ -1730,6 +1740,7 @@ This task already has an open pull request: ${prUrl}
After completing the requested changes:
1. Check out the existing PR branch with \`gh pr checkout ${prUrl}\`
2. Stage your changes with \`git add\`, then call the \`git_signed_commit\` tool with a clear \`message\` (do NOT use \`git commit\`/\`git push\` — they are blocked). This commits to the existing PR branch.
- If the branch has conflicts with its base, fetch and rebase locally (\`git fetch origin <base>\`, \`git rebase origin/<base>\`, resolve, \`git rebase --continue\`), then call the \`git_signed_rewrite\` tool to force-update this same PR branch.
3. For every PR review comment or review thread you addressed, treat the thread as done only after BOTH of these:
- Reply on the thread with a short note describing what changed (reference the commit SHA when useful) using \`gh api -X POST /repos/{owner}/{repo}/pulls/{n}/comments/{id}/replies -f body='...'\`.
- Resolve the thread via the \`resolveReviewThread\` GraphQL mutation: \`gh api graphql -f query='mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}' -f id="<thread-node-id>"\`.
Expand Down
39 changes: 38 additions & 1 deletion packages/git/src/signed-commit.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { chunkFileChanges, OversizedFileError } from "./signed-commit";
import {
chunkFileChanges,
OversizedFileError,
splitCommitMessage,
} from "./signed-commit";

function addition(path: string, sizeBytes: number) {
// base64 string of roughly `sizeBytes` length stands in for file contents.
Expand Down Expand Up @@ -56,3 +60,36 @@ describe("chunkFileChanges", () => {
).toThrow(OversizedFileError);
});
});

describe("splitCommitMessage", () => {
it.each([
{
name: "subject only",
raw: "fix: handle null",
expected: { headline: "fix: handle null", body: "" },
},
{
name: "subject + body, dropping the blank separator line",
raw: "feat: add thing\n\nDetails here.\nMore details.",
expected: {
headline: "feat: add thing",
body: "Details here.\nMore details.",
},
},
{
name: "preserves existing trailers in the body",
raw: "fix: x\n\nGenerated-By: PostHog Code\nTask-Id: abc",
expected: {
headline: "fix: x",
body: "Generated-By: PostHog Code\nTask-Id: abc",
},
},
{
name: "trims trailing whitespace",
raw: "chore: y\n\nbody\n\n",
expected: { headline: "chore: y", body: "body" },
},
])("$name", ({ raw, expected }) => {
expect(splitCommitMessage(raw)).toEqual(expected);
});
});
Comment thread
tatoalo marked this conversation as resolved.
Loading
Loading