Files
oh-my-pi/packages/coding-agent/src/tools/calculator.ts
T
can1357 2f871b6d23 feat(coding-agent): added loadMode and summary to AgentTool discovery
- Added optional `loadMode` and `summary` fields to `AgentTool` and related type declarations.
- Added `loadMode` and `summary` metadata to built-in tool classes for discoverable/essential behavior.
- Replaced `BUILTIN_TOOL_METADATA` with per-tool fields in discovery code paths.
- Updated `search_tool_bm25` and discovery indexing to use each tool's `summary` text.
- Updated discovery tests to validate tool `loadMode` and summary completeness.
2026-05-06 19:14:39 +02:00

540 lines
16 KiB
TypeScript

import type { AgentTool, AgentToolResult } 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 { prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { Theme } from "../modes/theme/theme";
import calculatorDescription from "../prompts/tools/calculator.md" with { type: "text" };
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import type { ToolSession } from ".";
import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS, TRUNCATE_LENGTHS } from "./render-utils";
// =============================================================================
// Token Types
// =============================================================================
/** Supported arithmetic operators (** is exponentiation). */
type Operator = "+" | "-" | "*" | "/" | "%" | "**";
/**
* Lexer token variants:
* - number: parsed numeric value with original string for error messages
* - operator: arithmetic operator
* - paren: grouping parenthesis
*/
type Token =
| { type: "number"; value: number; raw: string }
| { type: "operator"; value: Operator }
| { type: "paren"; value: "(" | ")" };
const calculatorSchema = Type.Object({
calculations: Type.Array(
Type.Object({
expression: Type.String({ description: "math expression", examples: ["2 + 2", "sqrt(16)"] }),
prefix: Type.String({ description: "prefix text" }),
suffix: Type.String({ description: "suffix text" }),
}),
{ description: "calculations to evaluate" },
),
});
export interface CalculatorToolDetails {
results: Array<{ expression: string; value: number; output: string }>;
}
// =============================================================================
// Character classification helpers for numeric literal parsing
// =============================================================================
function isDigit(ch: string): boolean {
return ch >= "0" && ch <= "9";
}
function isHexDigit(ch: string): boolean {
return (ch >= "0" && ch <= "9") || (ch >= "a" && ch <= "f") || (ch >= "A" && ch <= "F");
}
function isBinaryDigit(ch: string): boolean {
return ch === "0" || ch === "1";
}
function isOctalDigit(ch: string): boolean {
return ch >= "0" && ch <= "7";
}
// =============================================================================
// Tokenizer
// =============================================================================
/**
* Tokenize a math expression into numbers, operators, and parentheses.
*
* Number formats supported:
* - Decimal: 123, 3.14, .5
* - Scientific: 1e10, 2.5E-3
* - Hexadecimal: 0xFF
* - Binary: 0b1010
* - Octal: 0o755
*/
function tokenizeExpression(expression: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < expression.length) {
const ch = expression[i];
// Skip whitespace
if (ch.trim() === "") {
i += 1;
continue;
}
if (ch === "(" || ch === ")") {
tokens.push({ type: "paren", value: ch });
i += 1;
continue;
}
// Check ** before single * to handle exponentiation
if (ch === "*" && expression[i + 1] === "*") {
tokens.push({ type: "operator", value: "**" });
i += 2;
continue;
}
if (ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%") {
tokens.push({ type: "operator", value: ch });
i += 1;
continue;
}
// Number parsing: starts with digit or decimal point followed by digit
const next = expression[i + 1];
const numberStart = isDigit(ch) || (ch === "." && next !== undefined && isDigit(next));
if (!numberStart) {
throw new Error(`Invalid character "${ch}" in expression`);
}
const start = i;
// Handle prefixed literals (0x, 0b, 0o)
if (ch === "0" && next !== undefined) {
const prefix = next.toLowerCase();
if (prefix === "x" || prefix === "b" || prefix === "o") {
i += 2; // Skip "0x" / "0b" / "0o"
let hasDigit = false;
while (i < expression.length) {
const digit = expression[i];
const valid =
prefix === "x" ? isHexDigit(digit) : prefix === "b" ? isBinaryDigit(digit) : isOctalDigit(digit);
if (!valid) break;
hasDigit = true;
i += 1;
}
if (!hasDigit) {
throw new Error(`Invalid numeric literal starting at "${expression.slice(start, i)}"`);
}
const raw = expression.slice(start, i);
const value = Number(raw); // JS Number() handles 0x/0b/0o natively
if (!Number.isFinite(value)) {
throw new Error(`Invalid number "${raw}"`);
}
tokens.push({ type: "number", value, raw });
continue;
}
}
// Parse decimal number: integer part
let hasDigits = false;
while (i < expression.length && isDigit(expression[i])) {
hasDigits = true;
i += 1;
}
// Fractional part
if (expression[i] === ".") {
i += 1;
while (i < expression.length && isDigit(expression[i])) {
hasDigits = true;
i += 1;
}
}
if (!hasDigits) {
throw new Error(`Invalid number starting at "${expression.slice(start, i + 1)}"`);
}
// Scientific notation exponent (e.g., 1e10, 2.5E-3)
if (expression[i] === "e" || expression[i] === "E") {
i += 1;
if (expression[i] === "+" || expression[i] === "-") {
i += 1;
}
let hasExponentDigits = false;
while (i < expression.length && isDigit(expression[i])) {
hasExponentDigits = true;
i += 1;
}
if (!hasExponentDigits) {
throw new Error(`Invalid exponent in "${expression.slice(start, i)}"`);
}
}
const raw = expression.slice(start, i);
const value = Number(raw);
if (!Number.isFinite(value)) {
throw new Error(`Invalid number "${raw}"`);
}
tokens.push({ type: "number", value, raw });
}
return tokens;
}
// =============================================================================
// Recursive Descent Parser
// =============================================================================
/**
* Recursive descent parser for arithmetic expressions.
*
* Operator precedence (lowest to highest):
* 1. Addition, subtraction (+, -)
* 2. Multiplication, division, modulo (*, /, %)
* 3. Unary plus/minus (+x, -x)
* 4. Exponentiation (**)
* 5. Parentheses and literals
*
* Each precedence level has its own parse method. Lower precedence methods
* call higher precedence methods, building the AST implicitly through
* the call stack.
*/
class ExpressionParser {
#index = 0;
constructor(private readonly tokens: Token[]) {}
/** Parse the full expression and ensure all tokens are consumed. */
parse(): number {
const value = this.#parseExpression();
if (this.#index < this.tokens.length) {
throw new Error("Unexpected token in expression");
}
return value;
}
/**
* Parse addition and subtraction (lowest precedence).
* Left-associative: 1 - 2 - 3 = (1 - 2) - 3
*/
#parseExpression(): number {
let value = this.#parseTerm();
while (true) {
if (this.#matchOperator("+")) {
value += this.#parseTerm();
continue;
}
if (this.#matchOperator("-")) {
value -= this.#parseTerm();
continue;
}
break;
}
return value;
}
/**
* Parse multiplication, division, and modulo.
* Left-associative: 8 / 4 / 2 = (8 / 4) / 2
*/
#parseTerm(): number {
let value = this.#parseUnary();
while (true) {
if (this.#matchOperator("*")) {
value *= this.#parseUnary();
continue;
}
if (this.#matchOperator("/")) {
value /= this.#parseUnary();
continue;
}
if (this.#matchOperator("%")) {
value %= this.#parseUnary();
continue;
}
break;
}
return value;
}
/**
* Parse unary + and - operators.
* Recursive to handle chained unary: --x, +-x
*/
#parseUnary(): number {
if (this.#matchOperator("+")) {
return this.#parseUnary();
}
if (this.#matchOperator("-")) {
return -this.#parseUnary();
}
return this.#parsePower();
}
/**
* Parse exponentiation operator.
* Right-associative: 2 ** 3 ** 2 = 2 ** (3 ** 2) = 512
* Achieved by recursive call to parsePower for the right operand.
*/
#parsePower(): number {
let value = this.#parsePrimary();
if (this.#matchOperator("**")) {
value = value ** this.#parsePower(); // Right-associative via recursion
}
return value;
}
/**
* Parse primary expressions: number literals and parenthesized subexpressions.
* Parentheses restart parsing at lowest precedence (parseExpression).
*/
#parsePrimary(): number {
const token = this.#peek();
if (!token) {
throw new Error("Unexpected end of expression");
}
if (token.type === "number") {
this.#index += 1;
return token.value;
}
if (token.type === "paren" && token.value === "(") {
this.#index += 1;
const value = this.#parseExpression(); // Reset to lowest precedence
if (!this.#matchParen(")")) {
throw new Error("Missing closing parenthesis");
}
return value;
}
throw new Error("Unexpected token in expression");
}
/** Consume operator if it matches, advancing the token index. */
#matchOperator(value: Operator): boolean {
const token = this.tokens[this.#index];
if (token && token.type === "operator" && token.value === value) {
this.#index += 1;
return true;
}
return false;
}
/** Consume parenthesis if it matches, advancing the token index. */
#matchParen(value: "(" | ")"): boolean {
const token = this.tokens[this.#index];
if (token && token.type === "paren" && token.value === value) {
this.#index += 1;
return true;
}
return false;
}
/** Look at current token without consuming it. */
#peek(): Token | undefined {
return this.tokens[this.#index];
}
}
// =============================================================================
// Expression Evaluator
// =============================================================================
/**
* Evaluate a math expression string and return the numeric result.
*
* Pipeline: expression string -> tokens -> parse tree (implicit) -> value
*
* @throws Error on syntax errors, empty expressions, or non-finite results (Infinity, NaN)
*/
function evaluateExpression(expression: string): number {
const tokens = tokenizeExpression(expression);
if (tokens.length === 0) {
throw new Error("Expression is empty");
}
const parser = new ExpressionParser(tokens);
const value = parser.parse();
if (!Number.isFinite(value)) {
throw new Error("Expression result is not a finite number");
}
// Normalize -0 to 0 for consistent output
return Object.is(value, -0) ? 0 : value;
}
function formatResult(value: number): string {
return String(value);
}
// ═══════════════════════════════════════════════════════════════════════════
// Tool Class
// ═══════════════════════════════════════════════════════════════════════════
type CalculatorParams = Static<typeof calculatorSchema>;
/**
* Calculator tool for evaluating mathematical expressions.
*
* Supports decimal, hex (0x), binary (0b), octal (0o) literals,
* standard arithmetic operators, and parentheses.
*/
export class CalculatorTool implements AgentTool<typeof calculatorSchema, CalculatorToolDetails> {
readonly name = "calc";
readonly label = "Calc";
readonly summary = "Evaluate a mathematical expression";
readonly loadMode = "discoverable";
readonly description: string;
readonly parameters = calculatorSchema;
readonly strict = true;
constructor(_session: ToolSession) {
this.description = prompt.render(calculatorDescription);
}
async execute(
_toolCallId: string,
{ calculations }: CalculatorParams,
signal?: AbortSignal,
): Promise<AgentToolResult<CalculatorToolDetails>> {
return untilAborted(signal, async () => {
const results = calculations.map(calc => {
const value = evaluateExpression(calc.expression);
const output = `${calc.prefix}${formatResult(value)}${calc.suffix}`;
return { expression: calc.expression, value, output };
});
const outputText = results.map(result => result.output).join("\n");
return {
content: [{ type: "text", text: outputText }],
details: { results },
};
});
}
}
// =============================================================================
// TUI Renderer
// =============================================================================
interface CalculatorRenderArgs {
calculations?: Array<{ expression: string; prefix?: string; suffix?: string }>;
}
const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
/**
* TUI renderer for calculator tool calls and results.
* Handles both collapsed (preview) and expanded (full) display modes.
*/
export const calculatorToolRenderer = {
/**
* Render the tool call header showing the first expression and count.
* Format: "Calc <expression> (N calcs)"
*/
renderCall(args: CalculatorRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const count = args.calculations?.length ?? 0;
const firstExpression = args.calculations?.[0]?.expression;
const description = firstExpression ? truncateToWidth(firstExpression, TRUNCATE_LENGTHS.TITLE) : undefined;
const meta = count > 0 ? [formatCount("calc", count)] : [];
const text = renderStatusLine({ icon: "pending", title: "Calc", description, meta }, uiTheme);
return new Text(text, 0, 0);
},
/**
* Render calculation results as a tree list.
* Collapsed mode shows first N items with expand hint; expanded shows all.
*/
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: CalculatorToolDetails; isError?: boolean },
options: RenderResultOptions,
uiTheme: Theme,
args?: CalculatorRenderArgs,
): Component {
const details = result.details;
const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
if (result.isError) {
const header = renderStatusLine({ icon: "error", title: "Calc" }, uiTheme);
const renderedLines = [header, formatErrorMessage(textContent, uiTheme)];
return {
render() {
return renderedLines;
},
invalidate() {},
};
}
// Prefer structured details; fall back to parsing text content
let outputs = details?.results?.map(entry => `${entry.expression} = ${entry.output}`) ?? [];
if (outputs.length === 0 && textContent.trim()) {
const rawOutputs = textContent.split("\n").filter(line => line.trim().length > 0);
const expressions = args?.calculations?.map(calc => calc.expression) ?? [];
if (expressions.length === rawOutputs.length && expressions.length > 0) {
outputs = rawOutputs.map((output, index) => `${expressions[index]} = ${output}`);
} else {
outputs = rawOutputs;
}
}
if (outputs.length === 0) {
const header = renderStatusLine({ icon: "warning", title: "Calc" }, uiTheme);
const renderedLines = [header, formatEmptyMessage("No results", uiTheme)];
return {
render() {
return renderedLines;
},
invalidate() {},
};
}
const description = args?.calculations?.[0]?.expression
? truncateToWidth(args.calculations[0].expression, TRUNCATE_LENGTHS.TITLE)
: undefined;
const header = renderStatusLine(
{ icon: "success", title: "Calc", description, meta: [formatCount("result", outputs.length)] },
uiTheme,
);
let cached: RenderCache | undefined;
return {
render(width) {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
const treeLines = renderTreeList(
{
items: outputs,
expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT,
itemType: "result",
renderItem: output => uiTheme.fg("toolOutput", output),
},
uiTheme,
);
const lines = [header, ...treeLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines };
return lines;
},
invalidate() {
cached = undefined;
},
};
},
mergeCallAndResult: true,
};