Files
oh-my-pi/packages/coding-agent/src/tools/resolve.ts
T
can1357 c3d0bd9773 feat(coding-agent): deferrable tools, LIFO pending-action stack, custom tool pushPendingAction
- Introduce `deferrable?: boolean` on AgentTool, CustomTool, and ToolDefinition.
  AstEditTool sets it to true; resolve is now injected only when at least one
  active tool is deferrable (previously unconditional).

- Replace single-slot PendingActionStore (set/get/clear) with a LIFO stack
  (push/peek/pop/clear). Multiple deferrable tools can stage independent
  preview actions; resolve always consumes the topmost one first.

- Wire pendingActionStore through discoverAndLoadCustomTools / loadCustomTools /
  CustomToolLoader so custom tools can call pushPendingAction(action) to
  register a resolve-compatible pending action with label, apply callback,
  optional details, and optional sourceToolName.

- Export HIDDEN_TOOLS and ResolveTool from the SDK for manual tool composition.

- Add CustomToolPendingAction type and pushPendingAction to CustomToolAPI.

- Update createAgentSession to re-inject or remove resolve after the deferrable
  audit, consistent with createTools behavior.

- Add LIFO resolve test, update existing tests (set -> push, get -> peek).

- Add docs/resolve-tool-runtime.md covering PendingActionStore internals,
  built-in producer example, and custom tool usage guide.
2026-03-01 02:57:31 +01:00

151 lines
5.5 KiB
TypeScript

import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { Theme } from "../modes/theme/theme";
import resolveDescription from "../prompts/tools/resolve.md" with { type: "text" };
import { Ellipsis, padToWidth, renderStatusLine, truncateToWidth } from "../tui";
import type { ToolSession } from ".";
import { replaceTabs } from "./render-utils";
import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
const resolveSchema = Type.Object({
action: Type.Union([Type.Literal("apply"), Type.Literal("discard")]),
reason: Type.String({ description: "Why you're applying or discarding" }),
});
type ResolveParams = Static<typeof resolveSchema>;
export interface ResolveToolDetails {
action: "apply" | "discard";
reason: string;
sourceToolName?: string;
label?: string;
}
function resolveReasonPreview(reason?: string): string | undefined {
const trimmed = reason?.trim();
if (!trimmed) return undefined;
return truncateToWidth(trimmed, 72, Ellipsis.Omit);
}
export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolDetails> {
readonly name = "resolve";
readonly label = "Resolve";
readonly hidden = true;
readonly description: string;
readonly parameters = resolveSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {
this.description = renderPromptTemplate(resolveDescription);
}
async execute(
_toolCallId: string,
params: ResolveParams,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<ResolveToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<ResolveToolDetails>> {
return untilAborted(signal, async () => {
const store = this.session.pendingActionStore;
if (!store || !store.hasPending) {
throw new ToolError("No pending action to resolve. Nothing to apply or discard.");
}
const pendingAction = store.pop();
if (!pendingAction) {
throw new ToolError("No pending action to resolve. Nothing to apply or discard.");
}
const resolveDetails: ResolveToolDetails = {
action: params.action,
reason: params.reason,
sourceToolName: pendingAction.sourceToolName,
label: pendingAction.label,
};
if (params.action === "apply") {
const applyResult = await pendingAction.apply();
const appliedText = applyResult.content
.filter(part => part.type === "text")
.map(part => part.text)
.filter(text => text != null && text.length > 0)
.join("\n");
const baseResult = toolResult()
.text(appliedText || `Applied: ${pendingAction.label}.`)
.done();
return { ...baseResult, details: resolveDetails };
}
const discardResult = toolResult().text(`Discarded: ${pendingAction.label}. Reason: ${params.reason}`).done();
return { ...discardResult, details: resolveDetails };
});
}
}
export const resolveToolRenderer = {
renderCall(args: ResolveParams, _options: RenderResultOptions, uiTheme: Theme): Component {
const reason = resolveReasonPreview(args.reason);
const text = renderStatusLine(
{
icon: "pending",
title: "Resolve",
description: args.action,
badge: {
label: args.action === "apply" ? "proposed -> resolved" : "proposed -> rejected",
color: args.action === "apply" ? "success" : "warning",
},
meta: reason ? [uiTheme.fg("muted", reason)] : undefined,
},
uiTheme,
);
return new Text(text, 0, 0);
},
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: ResolveToolDetails; isError?: boolean },
_options: RenderResultOptions,
uiTheme: Theme,
): Component {
const details = result.details;
const label = replaceTabs(details?.label ?? "pending action");
const reason = replaceTabs(details?.reason?.trim() || "No reason provided");
const action = details?.action ?? "apply";
const isApply = action === "apply" && !result.isError;
const bgColor = result.isError ? "error" : isApply ? "success" : "warning";
const icon = isApply ? uiTheme.status.success : uiTheme.status.error;
const verb = isApply ? "Accept" : "Discard";
const separator = ": ";
const separatorIndex = label.indexOf(separator);
const sourceLabel = separatorIndex > 0 ? label.slice(0, separatorIndex).trim() : undefined;
const summaryLabel = separatorIndex > 0 ? label.slice(separatorIndex + separator.length).trim() : label;
const sourceBadge = sourceLabel
? uiTheme.bold(`${uiTheme.format.bracketLeft}${sourceLabel}${uiTheme.format.bracketRight}`)
: undefined;
const headerLine = `${icon} ${uiTheme.bold(`${verb}:`)} ${summaryLabel}${sourceBadge ? ` ${sourceBadge}` : ""}`;
const lines = ["", headerLine, "", uiTheme.italic(reason), ""];
return {
render(width: number) {
const lineWidth = Math.max(3, width);
const innerWidth = Math.max(1, lineWidth - 2);
return lines.map(line => {
const truncated = truncateToWidth(line, innerWidth, Ellipsis.Omit);
const framed = ` ${padToWidth(truncated, innerWidth)} `;
const padded = padToWidth(framed, lineWidth);
return uiTheme.inverse(uiTheme.fg(bgColor, padded));
});
},
invalidate() {},
};
},
inline: true,
mergeCallAndResult: true,
};