64fcdc308f
- Replaced all StringEnum(...) usages with z.enum([...]) across tools, examples, and tests. - Removed StringEnum re-export from @oh-my-pi/pi-coding-agent public API. - Condensed verbose tool parameter descriptions to minimal lowercase phrases. - Renamed AuthCredentialStore to SqliteAuthCredentialStore at usage sites.
232 lines
7.4 KiB
TypeScript
232 lines
7.4 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 { formatNumber, prompt } from "@oh-my-pi/pi-utils";
|
|
import * as z from "zod/v4";
|
|
import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
|
|
import type { Theme, ThemeColor } from "../../modes/theme/theme";
|
|
import goalDescription from "../../prompts/tools/goal.md" with { type: "text" };
|
|
import { formatDuration } from "../../slash-commands/helpers/format";
|
|
import type { ToolSession } from "../../tools";
|
|
import { formatErrorMessage, TRUNCATE_LENGTHS } from "../../tools/render-utils";
|
|
import { ToolError } from "../../tools/tool-errors";
|
|
import { renderStatusLine, truncateToWidth } from "../../tui";
|
|
import { completionBudgetReport, remainingTokens } from "../runtime";
|
|
import type { Goal, GoalStatus, GoalToolDetails } from "../state";
|
|
|
|
const goalSchema = z.object({
|
|
op: z.enum(["create", "get", "complete"]).describe("goal operation"),
|
|
objective: z.string().describe("goal objective").optional(),
|
|
token_budget: z.number().int().describe("token budget").optional(),
|
|
});
|
|
|
|
export type GoalToolInput = z.infer<typeof goalSchema>;
|
|
|
|
export interface GoalToolResponse {
|
|
goal: Goal | null;
|
|
remainingTokens: number | null;
|
|
completionBudgetReport: string | null;
|
|
}
|
|
|
|
export function buildGoalToolResponse(
|
|
goal: Goal | null | undefined,
|
|
options?: { includeCompletionReport?: boolean },
|
|
): GoalToolResponse {
|
|
const resolvedGoal = goal ?? null;
|
|
return {
|
|
goal: resolvedGoal,
|
|
remainingTokens: remainingTokens(resolvedGoal),
|
|
completionBudgetReport:
|
|
options?.includeCompletionReport && resolvedGoal?.status === "complete"
|
|
? completionBudgetReport(resolvedGoal)
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function validateCreateParams(params: GoalToolInput): { objective: string; tokenBudget?: number } {
|
|
const objective = params.objective?.trim();
|
|
if (!objective) {
|
|
throw new ToolError("objective is required when op=create");
|
|
}
|
|
const tokenBudget = params.token_budget;
|
|
if (tokenBudget !== undefined && (!Number.isInteger(tokenBudget) || tokenBudget <= 0)) {
|
|
throw new ToolError("token_budget must be a positive integer when provided");
|
|
}
|
|
return { objective, tokenBudget };
|
|
}
|
|
|
|
export class GoalTool implements AgentTool<typeof goalSchema, GoalToolDetails> {
|
|
readonly name = "goal";
|
|
readonly label = "Goal";
|
|
readonly description = prompt.render(goalDescription);
|
|
readonly parameters = goalSchema;
|
|
readonly strict = true;
|
|
readonly intent = "omit" as const;
|
|
readonly #session: ToolSession;
|
|
|
|
constructor(session: ToolSession) {
|
|
this.#session = session;
|
|
}
|
|
|
|
async execute(
|
|
_toolCallId: string,
|
|
params: GoalToolInput,
|
|
_signal?: AbortSignal,
|
|
_onUpdate?: AgentToolUpdateCallback<GoalToolDetails>,
|
|
_context?: AgentToolContext,
|
|
): Promise<AgentToolResult<GoalToolDetails>> {
|
|
const runtime = this.#session.getGoalRuntime?.();
|
|
if (!runtime) {
|
|
throw new ToolError("Goal mode is not active.");
|
|
}
|
|
|
|
let response: GoalToolResponse;
|
|
if (params.op === "create") {
|
|
const created = await runtime.createGoal(validateCreateParams(params));
|
|
response = buildGoalToolResponse(created.goal);
|
|
} else if (params.op === "get") {
|
|
const state = this.#session.getGoalModeState?.();
|
|
response = buildGoalToolResponse(state?.enabled ? state.goal : null);
|
|
} else {
|
|
const completed = await runtime.completeGoalFromTool();
|
|
response = buildGoalToolResponse(completed, { includeCompletionReport: true });
|
|
}
|
|
let text: string;
|
|
if (response.goal) {
|
|
text = `Goal: ${response.goal.objective}\nStatus: ${response.goal.status}\nTokens: ${response.goal.tokensUsed} used`;
|
|
if (response.goal.tokenBudget !== undefined) {
|
|
text += ` / ${response.goal.tokenBudget} budget`;
|
|
}
|
|
if (response.remainingTokens !== null) {
|
|
text += `\nRemaining tokens: ${response.remainingTokens}`;
|
|
}
|
|
if (response.completionBudgetReport) {
|
|
text += `\n\n${response.completionBudgetReport}`;
|
|
}
|
|
} else {
|
|
text = "No active goal.";
|
|
}
|
|
return {
|
|
content: [{ type: "text", text }],
|
|
details: {
|
|
op: params.op,
|
|
goal: response.goal,
|
|
remainingTokens: response.remainingTokens,
|
|
completionBudgetReport: response.completionBudgetReport,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
function describeOp(op: string | undefined): string {
|
|
switch (op) {
|
|
case "create":
|
|
return "set";
|
|
case "complete":
|
|
return "complete";
|
|
case "get":
|
|
return "check";
|
|
default:
|
|
return op ?? "?";
|
|
}
|
|
}
|
|
|
|
function goalBadgeColor(status: GoalStatus): ThemeColor {
|
|
switch (status) {
|
|
case "complete":
|
|
return "success";
|
|
case "budget-limited":
|
|
return "warning";
|
|
case "paused":
|
|
case "dropped":
|
|
return "muted";
|
|
default:
|
|
return "accent";
|
|
}
|
|
}
|
|
|
|
interface GoalRenderArgs {
|
|
op?: GoalToolInput["op"];
|
|
objective?: string;
|
|
token_budget?: number;
|
|
}
|
|
|
|
export const goalToolRenderer = {
|
|
renderCall(args: GoalRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
const description = describeOp(args.op);
|
|
const meta: string[] = [];
|
|
const trimmedObjective = args.objective?.trim();
|
|
if (args.op === "create" && trimmedObjective) {
|
|
const objective = truncateToWidth(trimmedObjective, TRUNCATE_LENGTHS.TITLE);
|
|
meta.push(uiTheme.italic(uiTheme.fg("muted", `"${objective}"`)));
|
|
}
|
|
if (args.op === "create" && args.token_budget !== undefined) {
|
|
meta.push(`budget ${formatNumber(args.token_budget)}`);
|
|
}
|
|
const text = renderStatusLine({ icon: "pending", title: "Goal", description, meta }, uiTheme);
|
|
return new Text(text, 0, 0);
|
|
},
|
|
|
|
renderResult(
|
|
result: { content: Array<{ type: string; text?: string }>; details?: GoalToolDetails; isError?: boolean },
|
|
_options: RenderResultOptions,
|
|
uiTheme: Theme,
|
|
args?: GoalRenderArgs,
|
|
): Component {
|
|
const fallbackText = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
const details = result.details;
|
|
const op = details?.op ?? args?.op;
|
|
const description = describeOp(op);
|
|
|
|
if (result.isError) {
|
|
const header = renderStatusLine({ icon: "error", title: "Goal", description }, uiTheme);
|
|
const body = formatErrorMessage(fallbackText || "Goal tool failed", uiTheme);
|
|
return new Text([header, body].join("\n"), 0, 0);
|
|
}
|
|
|
|
const goal = details?.goal ?? null;
|
|
if (!goal) {
|
|
const header = renderStatusLine({ icon: "warning", title: "Goal", description }, uiTheme);
|
|
const body = uiTheme.fg("muted", "No active goal.");
|
|
return new Text([header, body].join("\n"), 0, 0);
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
lines.push(
|
|
renderStatusLine(
|
|
{
|
|
icon: "success",
|
|
title: "Goal",
|
|
description,
|
|
badge: { label: goal.status, color: goalBadgeColor(goal.status) },
|
|
},
|
|
uiTheme,
|
|
),
|
|
);
|
|
|
|
const objectiveText = truncateToWidth(goal.objective.trim(), TRUNCATE_LENGTHS.LONG);
|
|
lines.push(` ${uiTheme.italic(uiTheme.fg("muted", `"${objectiveText}"`))}`);
|
|
|
|
const used = formatNumber(goal.tokensUsed);
|
|
const tokensLine =
|
|
goal.tokenBudget !== undefined
|
|
? `${used} / ${formatNumber(goal.tokenBudget)} tokens (${formatNumber(Math.max(0, goal.tokenBudget - goal.tokensUsed))} left)`
|
|
: `${used} tokens`;
|
|
lines.push(` ${uiTheme.fg("dim", tokensLine)}`);
|
|
|
|
if (goal.timeUsedSeconds > 0) {
|
|
lines.push(` ${uiTheme.fg("dim", `${formatDuration(goal.timeUsedSeconds * 1000)} elapsed`)}`);
|
|
}
|
|
|
|
const report = details?.completionBudgetReport;
|
|
if (report) {
|
|
lines.push("");
|
|
lines.push(uiTheme.italic(uiTheme.fg("muted", report)));
|
|
}
|
|
|
|
return new Text(lines.join("\n"), 0, 0);
|
|
},
|
|
|
|
mergeCallAndResult: true,
|
|
};
|