external.ts
Rama main · réplica de lectura del repositorio original.
/**
* ⚠️ LIMITED IMPORTS - this file is imported by Next.js and must avoid pulling in backend code.
* All shared constants, types, and data used by both the Next.js app and the action runtime live here.
* Other files in action/ re-export from this file for backward compatibility.
*/
import type { EffortPosition } from "./effort.ts";
import type { RouterTier } from "./models.ts";
// mcp name constant
export const pullfrogMcpName = "pullfrog";
/** @see {@link file://./agents/shared.ts} Agent interface that uses this type */
export type AgentId = "claude" | "codex" | "opencode";
/**
* format a tool name the way each agent's MCP client presents it to the model.
* claude code: mcp__pullfrog__select_mode
* codex: pullfrog__select_mode
* opencode: pullfrog_select_mode
*/
export function formatMcpToolRef(agentId: AgentId, toolName: string): string {
switch (agentId) {
case "claude":
return `mcp__${pullfrogMcpName}__${toolName}`;
case "codex":
return `${pullfrogMcpName}__${toolName}`;
case "opencode":
return `${pullfrogMcpName}_${toolName}`;
default:
return agentId satisfies never;
}
}
/**
* Every MCP tool name `text` references, parsed with the same naming scheme
* `formatMcpToolRef` emits. Deliberately lives next to that function so the
* producer and the parser cannot drift apart.
*
* The convention this assumes — and enforces, via `assertPromptToolRefs` — is
* that a prefixed token appearing anywhere in prompt text IS a tool reference.
* Prose that merely looks like one would read as a tool reference to the model
* too, so treating it as a mistake is correct.
*/
export function extractMcpToolRefs(agentId: AgentId, text: string): string[] {
const prefix = formatMcpToolRef(agentId, "");
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// the prefix must start a token. without this, opencode's `pullfrog_` prefix
// matches INSIDE a claude-style `mcp__pullfrog__shell` ref and captures
// `_shell`, which is never a registered name — every run on a repo whose
// prompt carried a claude-style ref died on the check below.
return [...text.matchAll(new RegExp(`(?<![A-Za-z0-9_])${escaped}([a-z0-9_]+)`, "g"))].map(
(match) => match[1]
);
}
/**
* Report when the assembled prompt advertises a tool the MCP server did not
* actually register.
*
* Prompt text and tool registration are decided in different files off
* overlapping conditions (`signedCommits`, `repoIntelligence`, `shell`, the
* `gh` mirror threshold, …), so they can silently disagree — and the failure
* mode is invisible: the agent burns turns calling a tool that isn't there, or
* never learns about one that is. `toolNames` comes straight back from
* `startMcpHttpServer`, i.e. the list the server registered rather than a
* second derivation of it, so there is nothing to keep in sync.
*
* WARNS rather than throws, and that is deliberate. The prompt embeds
* customer-authored text — repo instructions, an issue or PR body — so a
* customer who merely WRITES a prefixed token controls this predicate. Throwing
* handed them a way to fail every run on their repo, which is what happened to
* `arcainc/arca` on 0.1.56. A drift check that aborts a customer run is the
* same mistake 0.1.54 made; the drift still has to be visible, so it is logged.
*/
export function findDanglingPromptToolRefs(params: {
agentId: AgentId;
prompt: string;
toolNames: string[];
}): string[] {
const registered = new Set(params.toolNames);
return [...new Set(extractMcpToolRefs(params.agentId, params.prompt))]
.filter((name) => !registered.has(name))
.sort();
}
// reasoning effort lives in effort.ts — see wiki/effort.md
export type { EffortPosition } from "./effort.ts";
export {
DEFAULT_EFFORT_POSITION,
EFFORT_ALIASES,
isEffortPosition,
offeredRungs,
parseEffortPosition,
resolveRung,
rungLabel,
rungPosition,
SUBSIDY_RUNG,
} from "./effort.ts";
// model alias registry lives in models.ts — re-exported here for shared access
export type {
AutoTier,
ModelAlias,
ModelProvider,
ProviderConfig,
RouterTier,
} from "./models.ts";
export {
AUTO_EFFICIENT,
AUTO_INTELLIGENT,
AUTO_ROUTER,
autoLadder,
autoRoutedSlug,
DEFAULT_PROXY_MODEL,
defaultAutoTier,
getAutoSelectHintModel,
getModelEffortLevels,
getModelEnvVars,
getModelManagedCredentials,
getModelProvider,
getProviderDisplayName,
isAutoRouted,
isAutoSlug,
isAutoTier,
isCardGatedModel,
isOssAllowedModel,
isRouterTier,
KEYLESS_AUTH_ENV,
ladderRungs,
modelAliases,
modelHasStoredAuth,
modelIdLabel,
OSS_MODEL_ALLOWLIST,
OSS_RECOMMENDED_MODEL,
ossFundedModelNames,
PROVIDER_GATEWAY_HEADERS_ENV,
PROVIDER_GATEWAY_URL_ENV,
parseModel,
providers,
ROUTED_PROVIDERS,
ROUTER_LADDER,
ROUTER_TIERS,
resolveAutoTier,
resolveCliModel,
resolveDisplayAlias,
resolveModelRung,
resolveModelSlug,
resolveOpenRouterModel,
resolvePickModel,
resolveRoutedModel,
routerProxyOptOut,
TRIAL_FUNDED_USD_LIMIT,
} from "./models.ts";
// tool permission types shared with server dispatch
export type ToolPermission = "disabled" | "enabled";
export type ShellPermission = "disabled" | "restricted" | "enabled";
export type PushPermission = "disabled" | "restricted" | "enabled";
// workflow yml permissions for GITHUB_TOKEN
export type WorkflowPermissionValue = "read" | "write" | "none";
export type WorkflowIdTokenPermissionValue = "write" | "none";
export interface WorkflowPermissions {
actions?: WorkflowPermissionValue;
attestations?: WorkflowPermissionValue;
checks?: WorkflowPermissionValue;
contents?: WorkflowPermissionValue;
deployments?: WorkflowPermissionValue;
discussions?: WorkflowPermissionValue;
"id-token"?: WorkflowIdTokenPermissionValue;
issues?: WorkflowPermissionValue;
models?: WorkflowPermissionValue;
packages?: WorkflowPermissionValue;
pages?: WorkflowPermissionValue;
"pull-requests"?: WorkflowPermissionValue;
"repository-projects"?: WorkflowPermissionValue;
"security-events"?: WorkflowPermissionValue;
statuses?: WorkflowPermissionValue;
}
// permission level for the author who triggered the event
// matches GitHub's permission levels: admin > write > maintain > triage > read > none
export type AuthorPermission = "admin" | "maintain" | "write" | "triage" | "read" | "none";
// base interface for common payload event fields
interface BasePayloadEvent {
issue_number?: number;
is_pr?: boolean;
branch?: string;
/** title of the issue/PR (or contextual title for comments) */
title?: string;
/** primary content for this trigger (issue body, PR body, comment body, review body, etc.) */
body?: string | null;
comment_id?: number;
review_id?: number;
review_state?: string;
thread?: any;
pull_request?: any;
check_suite?: {
id: number;
head_sha: string;
head_branch: string | null;
status: string | null;
conclusion: string | null;
url: string;
};
comment_ids?: number[] | "all";
/** permission level of the user who triggered this event */
authorPermission?: AuthorPermission | undefined;
/** when true, runs silently without progress comments (e.g., auto-labeling) */
silent?: boolean;
[key: string]: any;
}
interface PullRequestOpenedEvent extends BasePayloadEvent {
trigger: "pull_request_opened";
issue_number: number;
is_pr: true;
title: string;
body: string | null;
branch: string;
}
interface PullRequestReadyForReviewEvent extends BasePayloadEvent {
trigger: "pull_request_ready_for_review";
issue_number: number;
is_pr: true;
title: string;
body: string | null;
branch: string;
}
interface PullRequestReviewRequestedEvent extends BasePayloadEvent {
trigger: "pull_request_review_requested";
issue_number: number;
is_pr: true;
title: string;
body: string | null;
branch: string;
}
interface PullRequestReviewSubmittedEvent extends BasePayloadEvent {
trigger: "pull_request_review_submitted";
issue_number: number;
is_pr: true;
review_id: number;
/** review body is the primary content */
body: string | null;
review_state: string;
branch: string;
/**
* How far past the dispatching review this run may read: `all` on a
* Pullfrog-authored PR, `mentions` when only `@pullfrog`-mentioning threads
* are in scope. The server already computes this to decide whether to
* dispatch; forwarding it is what lets `get_review_comments` pick up the
* comments that land while the run is working. Optional so a payload from an
* older server build still parses against a newer action.
*/
address_scope?: "all" | "mentions" | undefined;
}
interface PullRequestReviewCommentCreatedEvent extends BasePayloadEvent {
trigger: "pull_request_review_comment_created";
issue_number: number;
is_pr: true;
title: string;
comment_id: number;
/** comment body is the primary content (null if already in prompt) */
body: string | null;
thread?: any;
branch: string;
}
interface IssuesOpenedEvent extends BasePayloadEvent {
trigger: "issues_opened";
issue_number: number;
title: string;
body: string | null;
}
interface IssuesAssignedEvent extends BasePayloadEvent {
trigger: "issues_assigned";
issue_number: number;
title: string;
body: string | null;
}
interface IssuesLabeledEvent extends BasePayloadEvent {
trigger: "issues_labeled";
issue_number: number;
title: string;
body: string | null;
}
interface IssueCommentCreatedEvent extends BasePayloadEvent {
trigger: "issue_comment_created";
comment_id: number;
/** distinguishes this from PR review comments (which use pull_request_review_comment_created) */
comment_type: "issue";
/** comment body is the primary content (null if already in prompt) */
body: string | null;
issue_number: number;
// PR-specific fields (only present when is_pr is true)
is_pr?: true;
branch?: string;
title?: string;
}
interface CheckSuiteCompletedEvent extends BasePayloadEvent {
trigger: "check_suite_completed";
issue_number: number;
is_pr: true;
title: string;
body: string | null;
pull_request: any;
branch: string;
check_suite: {
id: number;
head_sha: string;
head_branch: string | null;
status: string | null;
conclusion: string | null;
url: string;
};
}
interface WorkflowDispatchEvent extends BasePayloadEvent {
trigger: "workflow_dispatch";
}
interface FixReviewEvent extends BasePayloadEvent {
trigger: "fix_review";
issue_number: number;
is_pr: true;
review_id: number;
/** when true, only address comments the triggerer approved with 👍 (vs all comments) */
approved_only?: boolean | undefined;
}
interface ImplementPlanEvent extends BasePayloadEvent {
trigger: "implement_plan";
issue_number: number;
is_pr?: true;
plan_comment_id: number;
/** plan content is the primary content (null if already in prompt) */
body: string | null;
}
interface PullRequestSynchronizeEvent extends BasePayloadEvent {
trigger: "pull_request_synchronize";
issue_number: number;
is_pr: true;
title: string;
body: string | null;
branch: string;
/** SHA before the push -- used to compute incremental range-diff between PR versions */
before_sha: string;
}
interface UnknownEvent extends BasePayloadEvent {
trigger: "unknown";
}
// discriminated union for payload event based on trigger
// note: all events use issue_number for consistency (PRs are issues in GitHub's API)
export type PayloadEvent =
| PullRequestOpenedEvent
| PullRequestReadyForReviewEvent
| PullRequestSynchronizeEvent
| PullRequestReviewRequestedEvent
| PullRequestReviewSubmittedEvent
| PullRequestReviewCommentCreatedEvent
| IssuesOpenedEvent
| IssuesAssignedEvent
| IssuesLabeledEvent
| IssueCommentCreatedEvent
| CheckSuiteCompletedEvent
| WorkflowDispatchEvent
| FixReviewEvent
| ImplementPlanEvent
| UnknownEvent;
/**
* cross-repo intent + resolved access sets, computed server-side from the
* `--xrepo` flag and the triggerer's own GitHub access. absent on every
* single-repo run (the default path is byte-identical to today). repo names
* are owner-implicit — a GitHub App installation is scoped to one account, so
* every entry shares the primary repo's owner. see utils/flags.ts + wiki.
*/
export interface XrepoConfig {
/** `all` = bare `--xrepo` (top-N active), `explicit` = `--xrepo=a,b` subset */
mode: "all" | "explicit";
/** repo names the triggerer can READ (clone-for-reference); includes primary */
read: string[];
/** repo names the triggerer can WRITE (open PRs); subset of `read` */
write: string[];
/**
* repos the triggerer explicitly named (`--xrepo=a,b`) that were NOT granted —
* unknown to the installation, a different owner, or excluded for lacking a
* verified per-repo permission. surfaced by `list_repos` so a narrowed request
* isn't silently swallowed. empty for bare `--xrepo`. optional (defaulted to
* `[]` on read) so an older server build that predates this field can't
* hard-fail payload parse against a newer action across a rolling deploy.
*/
unavailable?: string[] | undefined;
}
/**
* the model router's decision, carried so the runner can log it and forward the
* tier to run-context as `?tier=` — the Router proxy mint happens there and
* never sees `model` below. see wiki/router.md.
*/
export interface PayloadRouting {
tier: RouterTier;
stakes: number;
workload: number;
/** who decided: Jev, the chat scorer it falls back to, the heuristic behind that, or the fixed tier a non-review kind of work carries. mirrors the `RouterSource` enum. */
source: RouterSource;
/** the chat scorer's own sentence or two on what a reviewer must reconstruct; Jev's tier probabilities and axes instead */
rationale: string;
}
export type RouterSource = "jev" | "scorer" | "heuristic" | "fixed";
/** a rollout the server can assign a run to (`utils/rollouts.ts`). see wiki/rollouts.md. */
export type Rollout = "opencode-v2" | "opencode-v1" | "codenav";
// writeable payload type for building payloads
export interface WriteablePayload {
"~pullfrog": true;
/** semantic version of the payload to ensure compatibility */
version: string;
/**
* which kind of work this run is (`pr-created`, `issue-created`, …). the
* server's own run-type vocabulary, opaque here: the action only forwards it
* to `run-context`, which resolves the repo's per-trigger model override
* against it. absent ⇒ the repo default, which is what every run got before
* overrides existed.
*/
type?: string | undefined;
/** provider/model slug (e.g. "anthropic/claude-opus") */
model?: string | undefined;
/**
* true when `model` came from a per-run override flag (trigger / user prompt
* `--opus`, `--model=<slug>`). drives the model-access gate: an explicit,
* inaccessible model hard-fails; a standing default (repo setting or org/repo
* baseInstructions flag) keeps the soft-fallback safety net. see modelAccess.ts.
*/
modelExplicit?: boolean | undefined;
/** the model router's decision, when it made one; `model` above already reflects it */
routing?: PayloadRouting | undefined;
/** reasoning-effort position on [0,1]; 0 is the model's cheapest rung, 1 its priciest */
effort?: EffortPosition | undefined;
/**
* raise this one run's logging (`--debug`, or the `debug` action input): makes
* `log.debug` output visible and puts the opencode server at INFO. the only
* way to diagnose a run that fails with an empty log — see
* wiki/opencode-silent-stall.md. affects what we print, never what the agent
* may do, so it carries no access/permission consequence.
*/
debug?: boolean | undefined;
/** the user's actual request (body if @pullfrog tagged) */
prompt: string;
/** github username of the human who triggered this workflow run */
triggerer?: string | undefined;
/**
* org + repo standing instructions (`Account.baseInstructions` then
* `Repo.baseInstructions`), custom-alias-expanded and reserved-flag-stripped
* server-side. always applies, regardless of user-prompt precedence — the
* most-general levels of the leveled-config stack. see utils/flags.ts.
*/
baseInstructions?: string | undefined;
/** event-level instructions for this trigger type (flag-expanded server-side) */
eventInstructions?: string | undefined;
/**
* system-injected note about prior superseded runs (e.g. when the
* triggering @pullfrog comment is edited). rendered alongside the user's
* prompt rather than via eventInstructions so it survives user-prompt
* precedence.
*/
previousRunsNote?: string | undefined;
/** event data from webhook payload - discriminated union based on trigger field */
event: PayloadEvent;
/**
* cross-repo access sets, resolved server-side. absent ⇒ single-repo run
* (every cross-repo runtime branch gates on this being present).
*/
xrepo?: XrepoConfig | undefined;
/**
* opaque handle to the cross-repo grant the server persisted for this run.
* presented when minting cross-repo-scoped tokens; the server reads the repo
* sets from its own row, so this can widen nothing on its own. absent ⇒ the
* mint is scoped to the primary repo alone.
*/
xrepoGrant?: string | undefined;
/** timeout for agent run (e.g., "10m", "1h30m") - defaults to "1h" */
timeout?: string | undefined;
/** working directory for the agent */
cwd?: string | undefined;
/** pre-created progress comment (ID + type) for updating status */
progressComment?: { id: string; type: "issue" | "review" } | undefined;
/**
* pre-created `pullfrog` check-run, seeded `in_progress` by the server at dispatch so
* the PR's checks list shows the run before the GHA runner boots. the action PATCHes
* it to a terminal conclusion at run end. see `action/utils/runStatusCheck.ts`.
*/
checkRun?: { id: string } | undefined;
/** when true, seed the PR summary tmpfile + persist edits at run end */
generateSummary?: boolean | undefined;
/**
* the codex canary assigned this run to the EXPERIMENTAL codex harness. rolled
* once server-side at reservation and stamped on the run row, so the row and the
* run always agree on which arm this was. ORed with the account opt-in in
* `resolveAgent` — it widens who reaches codex, never narrows it.
*/
codexArm?: boolean | undefined;
/**
* the rollouts this run was assigned to, drawn once server-side at reservation and stamped on the
* run row. a name this build does not know is ignored, so the server may run ahead of a release.
*/
rollouts?: string[] | undefined;
}
// immutable payload type for agent execution
export type Payload = Readonly<WriteablePayload>;
/**
* Whether the run's own target is an issue, so it has no pull request to review.
* False on a targetless prompt run, which may review any PR its prompt names.
*/
export function isIssueTarget(event: PayloadEvent): boolean {
return event.issue_number !== undefined && !event.is_pr;
}
/**
* Whether `find_similar_issues` is registered for this run: an entitled account,
* on an issue or a run with no target (`pullfrog run "enrich issue #42"`), never a PR.
* Shared by the MCP registration and the prompt so the duplicate-detection
* instructions can never describe an absent tool.
*/
export function hasSimilarIssues(params: {
repoIntelligence: boolean;
event: PayloadEvent;
}): boolean {
return params.repoIntelligence && !params.event.is_pr;
}