From 020beb56167dbaffcec2d3b7c9d9c1e3e8a422fa Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 10 Jan 2026 03:34:07 +0100 Subject: [PATCH] feat(coding-agent): added calc tool, multi-credential round-robin, auth.json file locking, clickable OAuth URLs - Added calc tool supporting basic mathematical calculations with arithmetic operators, parentheses, and hex/binary/octal literals. - Added support for multiple API credentials per provider with round-robin distribution across sessions. - Added file locking on auth.json to prevent concurrent write corruption. - Added clickable OAuth login URL display in terminal with plain text fallback. - Updated ask tool prompt to emphasize exhausting other options before asking users. - Refactored OAuth credential storage to append new credentials instead of replacing existing ones. --- packages/coding-agent/CHANGELOG.md | 5 +- packages/coding-agent/README.md | 9 +- .../coding-agent/src/core/agent-session.ts | 16 +- .../coding-agent/src/core/auth-storage.ts | 321 ++++++++++- .../coding-agent/src/core/model-registry.ts | 15 +- packages/coding-agent/src/core/sdk.ts | 9 +- .../coding-agent/src/core/system-prompt.ts | 1 + .../coding-agent/src/core/title-generator.ts | 4 +- .../coding-agent/src/core/tools/calculator.ts | 500 ++++++++++++++++++ .../coding-agent/src/core/tools/index.test.ts | 2 + packages/coding-agent/src/core/tools/index.ts | 3 + .../coding-agent/src/core/tools/renderers.ts | 2 + .../src/core/tools/web-search/auth.ts | 18 +- .../src/core/tools/web-search/types.ts | 18 +- .../interactive/components/oauth-selector.ts | 3 +- .../src/modes/interactive/interactive-mode.ts | 7 +- .../coding-agent/src/prompts/tools/ask.md | 16 +- .../src/prompts/tools/calculator.md | 8 + 18 files changed, 882 insertions(+), 75 deletions(-) create mode 100644 packages/coding-agent/src/core/tools/calculator.ts create mode 100644 packages/coding-agent/src/prompts/tools/calculator.md diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4abd3314f..b1618b353 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,12 @@ # Changelog ## [Unreleased] - ### Added +- Added `calc` tool for basic mathematical calculations with support for arithmetic operators, parentheses, and hex/binary/octal literals +- Added support for multiple API credentials per provider with round-robin distribution across sessions +- Added file locking for auth.json to prevent concurrent write corruption +- Added clickable OAuth login URL display in terminal - Added `workdir` parameter to bash tool to execute commands in a specific directory without requiring `cd` commands ### Changed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index a845b515b..8d69e9245 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -114,10 +114,15 @@ Add API keys to `~/.omp/agent/auth.json`: ```json { - "anthropic": { "type": "api_key", "key": "sk-ant-..." }, + "anthropic": [ + { "type": "api_key", "key": "sk-ant-..." }, + { "type": "api_key", "key": "sk-ant-..." } + ], "openai": { "type": "api_key", "key": "sk-..." }, "google": { "type": "api_key", "key": "..." } } + +If a provider has multiple credentials, new sessions round robin across them and stay sticky per session. ``` **Option 2: Environment variables** @@ -152,7 +157,7 @@ omp /login # Select provider, authorize in browser ``` -**Note:** `/login` replaces any existing API key for that provider with OAuth credentials in `auth.json`. +**Note:** `/login` replaces any existing API keys for that provider with OAuth credentials in `auth.json`. If OAuth credentials already exist, `/login` appends another entry. **GitHub Copilot notes:** diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index a63ef72e0..73fdf0504 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -745,7 +745,7 @@ export class AgentSession { } // Validate API key - const apiKey = await this._modelRegistry.getApiKey(this.model); + const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId); if (!apiKey) { throw new Error( `No API key found for ${this.model.provider}.\n\n` + @@ -1142,7 +1142,7 @@ export class AgentSession { * @throws Error if no API key available for the model */ async setModel(model: Model, role: string = "default"): Promise { - const apiKey = await this._modelRegistry.getApiKey(model); + const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${model.provider}/${model.id}`); } @@ -1161,7 +1161,7 @@ export class AgentSession { * @throws Error if no API key available for the model */ async setModelTemporary(model: Model): Promise { - const apiKey = await this._modelRegistry.getApiKey(model); + const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${model.provider}/${model.id}`); } @@ -1255,7 +1255,7 @@ export class AgentSession { const next = this._scopedModels[nextIndex]; // Validate API key - const apiKey = await this._modelRegistry.getApiKey(next.model); + const apiKey = await this._modelRegistry.getApiKey(next.model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${next.model.provider}/${next.model.id}`); } @@ -1283,7 +1283,7 @@ export class AgentSession { const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const nextModel = availableModels[nextIndex]; - const apiKey = await this._modelRegistry.getApiKey(nextModel); + const apiKey = await this._modelRegistry.getApiKey(nextModel, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`); } @@ -1413,7 +1413,7 @@ export class AgentSession { throw new Error("No model selected"); } - const apiKey = await this._modelRegistry.getApiKey(this.model); + const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${this.model.provider}`); } @@ -1698,7 +1698,7 @@ export class AgentSession { let lastError: unknown; for (const candidate of candidates) { - const apiKey = await this._modelRegistry.getApiKey(candidate); + const apiKey = await this._modelRegistry.getApiKey(candidate, this.sessionId); if (!apiKey) continue; let attempt = 0; @@ -2344,7 +2344,7 @@ export class AgentSession { let summaryDetails: unknown; if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) { const model = this.model!; - const apiKey = await this._modelRegistry.getApiKey(model); + const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${model.provider}`); } diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 8ef89585c..646bca3dd 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -3,7 +3,17 @@ * Handles loading, saving, and refreshing credentials from auth.json. */ -import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { + chmodSync, + closeSync, + existsSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { dirname } from "node:path"; import { getEnvApiKey, @@ -29,15 +39,26 @@ export type OAuthCredential = { export type AuthCredential = ApiKeyCredential | OAuthCredential; -export type AuthStorageData = Record; +export type AuthCredentialEntry = AuthCredential | AuthCredential[]; + +export type AuthStorageData = Record; /** * Credential storage backed by a JSON file. * Reads from multiple fallback paths, writes to primary path. */ export class AuthStorage { + // File locking configuration for concurrent access protection + private static readonly lockRetryDelayMs = 50; // Polling interval when waiting for lock + private static readonly lockTimeoutMs = 5000; // Max wait time before failing + private static readonly lockStaleMs = 30000; // Age threshold for auto-removing orphaned locks + private data: AuthStorageData = {}; private runtimeOverrides: Map = new Map(); + /** Tracks next credential index per provider:type key for round-robin distribution */ + private providerRoundRobinIndex: Map = new Map(); + /** Maps provider:type -> sessionId -> credentialIndex for session-sticky credential assignment */ + private sessionCredentialIndexes: Map> = new Map(); private fallbackResolver?: (provider: string) => string | undefined; /** @@ -105,24 +126,244 @@ export class AuthStorage { * Save credentials to disk. */ private async save(): Promise { - writeFileSync(this.authPath, JSON.stringify(this.data, null, 2)); - chmodSync(this.authPath, 0o600); - const dir = dirname(this.authPath); - chmodSync(dir, 0o700); + const lockFd = await this.acquireLock(); + const tempPath = this.getTempPath(); + + try { + writeFileSync(tempPath, JSON.stringify(this.data, null, 2), { mode: 0o600 }); + renameSync(tempPath, this.authPath); + chmodSync(this.authPath, 0o600); + const dir = dirname(this.authPath); + chmodSync(dir, 0o700); + } finally { + this.safeUnlink(tempPath); + this.releaseLock(lockFd); + } + } + + /** Returns the lock file path (auth.json.lock) */ + private getLockPath(): string { + return `${this.authPath}.lock`; + } + + /** Returns a unique temp file path using pid and timestamp to avoid collisions */ + private getTempPath(): string { + return `${this.authPath}.tmp-${process.pid}-${Date.now()}`; + } + + /** Checks if lock file is older than lockStaleMs (orphaned by crashed process) */ + private isLockStale(lockPath: string): boolean { + try { + const stats = statSync(lockPath); + return Date.now() - stats.mtimeMs > AuthStorage.lockStaleMs; + } catch { + return false; + } } /** - * Get credential for a provider. + * Acquires exclusive file lock using O_EXCL atomic create. + * Polls with exponential backoff, removes stale locks from crashed processes. + * @returns File descriptor for the lock (must be passed to releaseLock) + */ + private async acquireLock(): Promise { + const lockPath = this.getLockPath(); + const start = Date.now(); + const timeoutMs = AuthStorage.lockTimeoutMs; + const retryDelayMs = AuthStorage.lockRetryDelayMs; + + while (true) { + try { + // O_EXCL fails if file exists, providing atomic lock acquisition + return openSync(lockPath, "wx", 0o600); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "EEXIST") { + throw err; + } + if (this.isLockStale(lockPath)) { + this.safeUnlink(lockPath); + logger.warn("AuthStorage lock was stale, removing", { path: lockPath }); + continue; + } + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for auth lock: ${lockPath}`); + } + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + } + + /** Releases file lock by closing fd and removing lock file */ + private releaseLock(lockFd: number): void { + const lockPath = this.getLockPath(); + try { + closeSync(lockFd); + } catch (error) { + logger.warn("AuthStorage failed to close lock file", { error: String(error) }); + } + this.safeUnlink(lockPath); + } + + /** Removes file if it exists, ignoring ENOENT errors */ + private safeUnlink(path: string): void { + try { + unlinkSync(path); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "ENOENT") { + logger.warn("AuthStorage failed to remove file", { path, error: String(error) }); + } + } + } + + /** Normalizes credential storage format: single credential becomes array of one */ + private normalizeCredentialEntry(entry: AuthCredentialEntry | undefined): AuthCredential[] { + if (!entry) return []; + return Array.isArray(entry) ? entry : [entry]; + } + + /** Returns all credentials for a provider as an array */ + private getCredentialsForProvider(provider: string): AuthCredential[] { + return this.normalizeCredentialEntry(this.data[provider]); + } + + /** Composite key for round-robin tracking: "anthropic:oauth" or "openai:api_key" */ + private getProviderTypeKey(provider: string, type: AuthCredential["type"]): string { + return `${provider}:${type}`; + } + + /** + * Returns next index in round-robin sequence for load distribution. + * Increments stored counter and wraps at total. + */ + private getNextRoundRobinIndex(providerKey: string, total: number): number { + if (total <= 1) return 0; + const current = this.providerRoundRobinIndex.get(providerKey) ?? -1; + const next = (current + 1) % total; + this.providerRoundRobinIndex.set(providerKey, next); + return next; + } + + /** + * Selects credential index with session affinity. + * Sessions reuse their assigned credential; new sessions get next round-robin index. + * This ensures a session always uses the same credential for consistency. + */ + private selectCredentialIndex(providerKey: string, sessionId: string | undefined, total: number): number { + if (total <= 1) return 0; + if (!sessionId) return 0; + + const sessionMap = this.sessionCredentialIndexes.get(providerKey); + const existing = sessionMap?.get(sessionId); + if (existing !== undefined && existing < total) { + return existing; + } + + // New session: assign next round-robin credential and cache the assignment + const next = this.getNextRoundRobinIndex(providerKey, total); + const updatedSessionMap = sessionMap ?? new Map(); + updatedSessionMap.set(sessionId, next); + this.sessionCredentialIndexes.set(providerKey, updatedSessionMap); + return next; + } + + /** + * Selects a credential of the specified type for a provider. + * Returns both the credential and its index in the original array (for updates/removal). + * Uses session-sticky selection when multiple credentials exist. + */ + private selectCredentialByType( + provider: string, + type: T, + sessionId?: string, + ): { credential: Extract; index: number } | undefined { + const credentials = this.getCredentialsForProvider(provider) + .map((credential, index) => ({ credential, index })) + .filter( + (entry): entry is { credential: Extract; index: number } => + entry.credential.type === type, + ); + + if (credentials.length === 0) return undefined; + if (credentials.length === 1) return credentials[0]; + + const providerKey = this.getProviderTypeKey(provider, type); + const selectedIndex = this.selectCredentialIndex(providerKey, sessionId, credentials.length); + return credentials[selectedIndex]; + } + + /** + * Clears round-robin and session assignment state for a provider. + * Called when credentials are added/removed to prevent stale index references. + */ + private resetProviderAssignments(provider: string): void { + for (const key of this.providerRoundRobinIndex.keys()) { + if (key.startsWith(`${provider}:`)) { + this.providerRoundRobinIndex.delete(key); + } + } + for (const key of this.sessionCredentialIndexes.keys()) { + if (key.startsWith(`${provider}:`)) { + this.sessionCredentialIndexes.delete(key); + } + } + } + + /** Updates credential at index in-place (used for OAuth token refresh) */ + private replaceCredentialAt(provider: string, index: number, credential: AuthCredential): void { + const entry = this.data[provider]; + if (!entry) return; + + if (Array.isArray(entry)) { + if (index >= 0 && index < entry.length) { + const updated = [...entry]; + updated[index] = credential; + this.data[provider] = updated; + } + return; + } + + if (index === 0) { + this.data[provider] = credential; + } + } + + /** + * Removes credential at index (used when OAuth refresh fails). + * Cleans up provider entry if last credential removed. + */ + private removeCredentialAt(provider: string, index: number): void { + const entry = this.data[provider]; + if (!entry) return; + + if (Array.isArray(entry)) { + const updated = entry.filter((_value, idx) => idx !== index); + if (updated.length > 0) { + this.data[provider] = updated; + } else { + delete this.data[provider]; + } + } else { + delete this.data[provider]; + } + + this.resetProviderAssignments(provider); + } + + /** + * Get credential for a provider (first entry if multiple). */ get(provider: string): AuthCredential | undefined { - return this.data[provider] ?? undefined; + return this.getCredentialsForProvider(provider)[0]; } /** * Set credential for a provider. */ - async set(provider: string, credential: AuthCredential): Promise { + async set(provider: string, credential: AuthCredentialEntry): Promise { this.data[provider] = credential; + this.resetProviderAssignments(provider); await this.save(); } @@ -131,6 +372,7 @@ export class AuthStorage { */ async remove(provider: string): Promise { delete this.data[provider]; + this.resetProviderAssignments(provider); await this.save(); } @@ -145,7 +387,7 @@ export class AuthStorage { * Check if credentials exist for a provider in auth.json. */ has(provider: string): boolean { - return provider in this.data; + return this.getCredentialsForProvider(provider).length > 0; } /** @@ -154,14 +396,30 @@ export class AuthStorage { */ hasAuth(provider: string): boolean { if (this.runtimeOverrides.has(provider)) return true; - if (this.data[provider]) return true; + if (this.getCredentialsForProvider(provider).length > 0) return true; if (getEnvApiKey(provider)) return true; if (this.fallbackResolver?.(provider)) return true; return false; } /** - * Get all credentials (for passing to getOAuthApiKey). + * Check if OAuth credentials are configured for a provider. + */ + hasOAuth(provider: string): boolean { + return this.getCredentialsForProvider(provider).some((credential) => credential.type === "oauth"); + } + + /** + * Get OAuth credentials for a provider. + */ + getOAuthCredential(provider: string): OAuthCredential | undefined { + return this.getCredentialsForProvider(provider).find( + (credential): credential is OAuthCredential => credential.type === "oauth", + ); + } + + /** + * Get all credentials. */ getAll(): AuthStorageData { return { ...this.data }; @@ -207,7 +465,14 @@ export class AuthStorage { throw new Error(`Unknown OAuth provider: ${provider}`); } - await this.set(provider, { type: "oauth", ...credentials }); + const newCredential: OAuthCredential = { type: "oauth", ...credentials }; + const existing = this.getCredentialsForProvider(provider); + if (existing.length === 0) { + await this.set(provider, newCredential); + return; + } + + await this.set(provider, [...existing, newCredential]); } /** @@ -226,37 +491,37 @@ export class AuthStorage { * 4. Environment variable * 5. Fallback resolver (models.json custom providers) */ - async getApiKey(provider: string): Promise { + async getApiKey(provider: string, sessionId?: string): Promise { // Runtime override takes highest priority const runtimeKey = this.runtimeOverrides.get(provider); if (runtimeKey) { return runtimeKey; } - const cred = this.data[provider]; - - if (cred?.type === "api_key") { - return cred.key; + const apiKeySelection = this.selectCredentialByType(provider, "api_key", sessionId); + if (apiKeySelection) { + return apiKeySelection.credential.key; } - if (cred?.type === "oauth") { - // Filter to only oauth credentials for getOAuthApiKey - const oauthCreds: Record = {}; - for (const [key, value] of Object.entries(this.data)) { - if (value.type === "oauth") { - oauthCreds[key] = value; - } - } + const oauthSelection = this.selectCredentialByType(provider, "oauth", sessionId); + if (oauthSelection) { + const oauthCreds: Record = { + [provider]: oauthSelection.credential, + }; try { const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds); if (result) { - this.data[provider] = { type: "oauth", ...result.newCredentials }; + this.replaceCredentialAt(provider, oauthSelection.index, { type: "oauth", ...result.newCredentials }); await this.save(); return result.apiKey; } } catch { - await this.remove(provider); + this.removeCredentialAt(provider, oauthSelection.index); + await this.save(); + if (this.getCredentialsForProvider(provider).some((credential) => credential.type === "oauth")) { + return this.getApiKey(provider, sessionId); + } } } diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 6b522972b..1cede56cc 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -187,8 +187,8 @@ export class ModelRegistry { const combined = [...builtInModels, ...customModels]; // Update github-copilot base URL based on OAuth credentials - const copilotCred = this.authStorage.get("github-copilot"); - if (copilotCred?.type === "oauth") { + const copilotCred = this.authStorage.getOAuthCredential("github-copilot"); + if (copilotCred) { const domain = copilotCred.enterpriseUrl ? (normalizeDomain(copilotCred.enterpriseUrl) ?? undefined) : undefined; @@ -390,22 +390,21 @@ export class ModelRegistry { /** * Get API key for a model. */ - async getApiKey(model: Model): Promise { - return this.authStorage.getApiKey(model.provider); + async getApiKey(model: Model, sessionId?: string): Promise { + return this.authStorage.getApiKey(model.provider, sessionId); } /** * Get API key for a provider (e.g., "openai"). */ - async getApiKeyForProvider(provider: string): Promise { - return this.authStorage.getApiKey(provider); + async getApiKeyForProvider(provider: string, sessionId?: string): Promise { + return this.authStorage.getApiKey(provider, sessionId); } /** * Check if a model is using OAuth credentials (subscription). */ isUsingOAuth(model: Model): boolean { - const cred = this.authStorage.get(model.provider); - return cred?.type === "oauth"; + return this.authStorage.hasOAuth(model.provider); } } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index af317da50..d6e784a97 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -538,6 +538,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const sessionManager = options.sessionManager ?? SessionManager.create(cwd); time("sessionManager"); + const sessionId = sessionManager.getSessionId(); // Check if session has existing data to restore const existingSession = sessionManager.buildSessionContext(); @@ -554,7 +555,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const parsedModel = parseModelString(defaultModelStr); if (parsedModel) { const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id); - if (restoredModel && (await modelRegistry.getApiKey(restoredModel))) { + if (restoredModel && (await modelRegistry.getApiKey(restoredModel, sessionId))) { model = restoredModel; } } @@ -570,7 +571,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const parsedModel = parseModelString(settingsDefaultModel); if (parsedModel) { const settingsModel = modelRegistry.find(parsedModel.provider, parsedModel.id); - if (settingsModel && (await modelRegistry.getApiKey(settingsModel))) { + if (settingsModel && (await modelRegistry.getApiKey(settingsModel, sessionId))) { model = settingsModel; } } @@ -580,7 +581,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Fall back to first available model with a valid API key if (!model) { for (const m of modelRegistry.getAll()) { - if (await modelRegistry.getApiKey(m)) { + if (await modelRegistry.getApiKey(m, sessionId)) { model = m; break; } @@ -921,7 +922,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (!currentModel) { throw new Error("No model selected"); } - const key = await modelRegistry.getApiKey(currentModel); + const key = await modelRegistry.getApiKey(currentModel, sessionId); if (!key) { throw new Error(`No API key found for provider "${currentModel.provider}"`); } diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 64220ec7d..242412625 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -72,6 +72,7 @@ const toolDescriptions: Record = { ask: "Ask user for input or clarification", read: "Read file contents", bash: "Execute bash commands (npm, docker, etc.)", + calc: "{ calculations: array of { expression: string, prefix: string, suffix: string } } Basic calculations.", ssh: "Execute commands on remote hosts via SSH", edit: "Make surgical edits to files (find exact text and replace)", write: "Create or overwrite files", diff --git a/packages/coding-agent/src/core/title-generator.ts b/packages/coding-agent/src/core/title-generator.ts index e3f22e028..df86fb7e5 100644 --- a/packages/coding-agent/src/core/title-generator.ts +++ b/packages/coding-agent/src/core/title-generator.ts @@ -68,11 +68,13 @@ export async function findTitleModel(registry: ModelRegistry, savedSmolModel?: s * @param firstMessage The first user message * @param registry Model registry * @param savedSmolModel Optional saved smol model from settings (provider/modelId format) + * @param sessionId Optional session id for sticky API key selection */ export async function generateSessionTitle( firstMessage: string, registry: ModelRegistry, savedSmolModel?: string, + sessionId?: string, ): Promise { const candidates = getTitleModelCandidates(registry, savedSmolModel); if (candidates.length === 0) { @@ -86,7 +88,7 @@ export async function generateSessionTitle( const userMessage = `\n${truncatedMessage}\n`; for (const model of candidates) { - const apiKey = await registry.getApiKey(model); + const apiKey = await registry.getApiKey(model, sessionId); if (!apiKey) { logger.debug("title-generator: no API key for model", { provider: model.provider, id: model.id }); continue; diff --git a/packages/coding-agent/src/core/tools/calculator.ts b/packages/coding-agent/src/core/tools/calculator.ts new file mode 100644 index 000000000..71e4cd186 --- /dev/null +++ b/packages/coding-agent/src/core/tools/calculator.ts @@ -0,0 +1,500 @@ +import type { AgentTool } 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 { Type } from "@sinclair/typebox"; +import type { Theme } from "../../modes/interactive/theme/theme"; +import calculatorDescription from "../../prompts/tools/calculator.md" with { type: "text" }; +import type { RenderResultOptions } from "../custom-tools/types"; +import { untilAborted } from "../utils"; +import type { ToolSession } from "./index"; +import { + formatCount, + formatEmptyMessage, + formatExpandHint, + formatMeta, + formatMoreItems, + PREVIEW_LIMITS, + truncate, + 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 to evaluate" }), + prefix: Type.String({ description: "Text to prepend to the result" }), + suffix: Type.String({ description: "Text to append to the result" }), + }), + { description: "List of calculations to evaluate", minItems: 1 }, + ), +}); + +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 { + private 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 + */ + private 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 + */ + private 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 + */ + private 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. + */ + private 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). + */ + private 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. */ + private 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. */ + private 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. */ + private 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); +} + +export function createCalculatorTool(_session: ToolSession): AgentTool { + return { + name: "calc", + label: "Calc", + description: calculatorDescription, + parameters: calculatorSchema, + execute: async ( + _toolCallId: string, + { calculations }: { calculations: Array<{ expression: string; prefix: string; suffix: string }> }, + signal?: AbortSignal, + ) => { + 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 (N calcs)" + */ + renderCall(args: CalculatorRenderArgs, uiTheme: Theme): Component { + const label = uiTheme.fg("toolTitle", uiTheme.bold("Calc")); + const count = args.calculations?.length ?? 0; + const firstExpression = args.calculations?.[0]?.expression; + let text = label; + if (firstExpression) { + text += ` ${uiTheme.fg("accent", truncate(firstExpression, TRUNCATE_LENGTHS.TITLE, "..."))}`; + } + const meta: string[] = []; + if (count > 0) meta.push(formatCount("calc", count)); + text += formatMeta(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 }, + { expanded }: RenderResultOptions, + uiTheme: Theme, + ): Component { + const details = result.details; + const textContent = result.content?.find((c) => c.type === "text")?.text ?? ""; + + // Prefer structured details; fall back to parsing text content + let outputs = details?.results?.map((entry) => entry.output) ?? []; + if (outputs.length === 0 && textContent.trim()) { + outputs = textContent.split("\n").filter((line) => line.trim().length > 0); + } + + if (outputs.length === 0) { + return new Text(formatEmptyMessage("No results", uiTheme), 0, 0); + } + + // Limit visible items in collapsed mode + const maxItems = expanded ? outputs.length : Math.min(outputs.length, COLLAPSED_LIST_LIMIT); + const hasMore = outputs.length > maxItems; + const icon = uiTheme.styledSymbol("status.success", "success"); + const summary = uiTheme.fg("dim", formatCount("result", outputs.length)); + const expandHint = formatExpandHint(expanded, hasMore, uiTheme); + let text = `${icon} ${summary}${expandHint}`; + + // Render each result as a tree branch + for (let i = 0; i < maxItems; i += 1) { + const isLast = i === maxItems - 1 && !hasMore; + const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; + text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("toolOutput", outputs[i])}`; + } + + // Show overflow indicator for collapsed mode + if (hasMore) { + text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg( + "muted", + formatMoreItems(outputs.length - maxItems, "result", uiTheme), + )}`; + } + + return new Text(text, 0, 0); + }, +}; diff --git a/packages/coding-agent/src/core/tools/index.test.ts b/packages/coding-agent/src/core/tools/index.test.ts index 61e9b95b6..ae4c1f86a 100644 --- a/packages/coding-agent/src/core/tools/index.test.ts +++ b/packages/coding-agent/src/core/tools/index.test.ts @@ -20,6 +20,7 @@ describe("createTools", () => { // Core tools should always be present expect(names).toContain("bash"); + expect(names).toContain("calc"); expect(names).toContain("read"); expect(names).toContain("edit"); expect(names).toContain("write"); @@ -162,6 +163,7 @@ describe("createTools", () => { const expectedTools = [ "ask", "bash", + "calc", "ssh", "edit", "find", diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index d5423bb6e..56e257b54 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -1,5 +1,6 @@ export { type AskToolDetails, askTool, createAskTool } from "./ask"; export { type BashToolDetails, createBashTool } from "./bash"; +export { createCalculatorTool, type CalculatorToolDetails } from "./calculator"; export { createCompleteTool } from "./complete"; export { createEditTool } from "./edit"; // Exa MCP tools (22 tools) @@ -57,6 +58,7 @@ import type { EventBus } from "../event-bus"; import type { BashInterceptorRule } from "../settings-manager"; import { createAskTool } from "./ask"; import { createBashTool } from "./bash"; +import { createCalculatorTool } from "./calculator"; import { createCompleteTool } from "./complete"; import { createEditTool } from "./edit"; import { createFindTool } from "./find"; @@ -119,6 +121,7 @@ type ToolFactory = (session: ToolSession) => Tool | null | Promise; export const BUILTIN_TOOLS: Record = { ask: createAskTool, bash: createBashTool, + calc: createCalculatorTool, ssh: createSshTool, edit: createEditTool, find: createFindTool, diff --git a/packages/coding-agent/src/core/tools/renderers.ts b/packages/coding-agent/src/core/tools/renderers.ts index d8bf97b9d..ae44a3e5d 100644 --- a/packages/coding-agent/src/core/tools/renderers.ts +++ b/packages/coding-agent/src/core/tools/renderers.ts @@ -9,6 +9,7 @@ import type { Theme } from "../../modes/interactive/theme/theme"; import type { RenderResultOptions } from "../custom-tools/types"; import { askToolRenderer } from "./ask"; import { bashToolRenderer } from "./bash"; +import { calculatorToolRenderer } from "./calculator"; import { editToolRenderer } from "./edit"; import { findToolRenderer } from "./find"; import { grepToolRenderer } from "./grep"; @@ -37,6 +38,7 @@ type ToolRenderer = { export const toolRenderers: Record = { ask: askToolRenderer as ToolRenderer, bash: bashToolRenderer as ToolRenderer, + calc: calculatorToolRenderer as ToolRenderer, edit: editToolRenderer as ToolRenderer, find: findToolRenderer as ToolRenderer, grep: grepToolRenderer as ToolRenderer, diff --git a/packages/coding-agent/src/core/tools/web-search/auth.ts b/packages/coding-agent/src/core/tools/web-search/auth.ts index e45bbf3ab..ce875e24c 100644 --- a/packages/coding-agent/src/core/tools/web-search/auth.ts +++ b/packages/coding-agent/src/core/tools/web-search/auth.ts @@ -11,7 +11,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { getConfigDirPaths } from "../../../config"; -import type { AnthropicAuthConfig, AuthJson, ModelsJson } from "./types"; +import type { AnthropicAuthConfig, AnthropicOAuthCredential, AuthJson, ModelsJson } from "./types"; const DEFAULT_BASE_URL = "https://api.anthropic.com"; @@ -76,6 +76,11 @@ export function isOAuthToken(apiKey: string): boolean { return apiKey.includes("sk-ant-oat"); } +function normalizeAnthropicOAuthCredentials(entry: AuthJson["anthropic"] | undefined): AnthropicOAuthCredential[] { + if (!entry) return []; + return Array.isArray(entry) ? entry : [entry]; +} + /** * Find Anthropic auth config using 4-tier priority: * 1. ANTHROPIC_SEARCH_API_KEY / ANTHROPIC_SEARCH_BASE_URL @@ -126,13 +131,16 @@ export async function findAnthropicAuth(): Promise { } // 3. OAuth credentials in auth.json (with 5-minute expiry buffer, check all config dirs) + const expiryBuffer = 5 * 60 * 1000; // 5 minutes + const now = Date.now(); for (const configDir of configDirs) { const authJson = await readJson(path.join(configDir, "auth.json")); - if (authJson?.anthropic?.type === "oauth" && authJson.anthropic.access) { - const expiryBuffer = 5 * 60 * 1000; // 5 minutes - if (authJson.anthropic.expires > Date.now() + expiryBuffer) { + const credentials = normalizeAnthropicOAuthCredentials(authJson?.anthropic); + for (const credential of credentials) { + if (credential.type !== "oauth" || !credential.access) continue; + if (credential.expires > now + expiryBuffer) { return { - apiKey: authJson.anthropic.access, + apiKey: credential.access, baseUrl: DEFAULT_BASE_URL, isOAuth: true, }; diff --git a/packages/coding-agent/src/core/tools/web-search/types.ts b/packages/coding-agent/src/core/tools/web-search/types.ts index 493561378..398714695 100644 --- a/packages/coding-agent/src/core/tools/web-search/types.ts +++ b/packages/coding-agent/src/core/tools/web-search/types.ts @@ -90,14 +90,18 @@ export interface ModelsJson { } /** auth.json structure for OAuth credentials */ +export interface AnthropicOAuthCredential { + type: "oauth"; + access: string; + refresh?: string; + /** Expiry timestamp in milliseconds */ + expires: number; +} + +export type AnthropicAuthJsonEntry = AnthropicOAuthCredential | AnthropicOAuthCredential[]; + export interface AuthJson { - anthropic?: { - type: "oauth"; - access: string; - refresh?: string; - /** Expiry timestamp in milliseconds */ - expires: number; - }; + anthropic?: AnthropicAuthJsonEntry; } /** Anthropic API response types */ diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts index c2821cf99..17d469bbf 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -70,8 +70,7 @@ export class OAuthSelectorComponent extends Container { const isAvailable = provider.available; // Check if user is logged in for this provider - const credentials = this.authStorage.get(provider.id); - const isLoggedIn = credentials?.type === "oauth"; + const isLoggedIn = this.authStorage.hasOAuth(provider.id); const statusIndicator = isLoggedIn ? theme.fg("success", ` ${theme.status.success} logged in`) : ""; let line = ""; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 457205ca2..52f00f5d3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1157,7 +1157,7 @@ export class InteractiveMode { if (!hasUserMessages && !this.sessionManager.getSessionTitle()) { const registry = this.session.modelRegistry; const smolModel = this.settingsManager.getModelRole("smol"); - generateSessionTitle(text, registry, smolModel) + generateSessionTitle(text, registry, smolModel, this.session.sessionId) .then(async (title) => { if (title) { await this.sessionManager.setSessionTitle(title); @@ -2613,9 +2613,7 @@ export class InteractiveMode { private async showOAuthSelector(mode: "login" | "logout"): Promise { if (mode === "logout") { const providers = this.session.modelRegistry.authStorage.list(); - const loggedInProviders = providers.filter( - (p) => this.session.modelRegistry.authStorage.get(p)?.type === "oauth", - ); + const loggedInProviders = providers.filter((p) => this.session.modelRegistry.authStorage.hasOAuth(p)); if (loggedInProviders.length === 0) { this.showStatus("No OAuth providers logged in. Use /login first."); return; @@ -2636,6 +2634,7 @@ export class InteractiveMode { await this.session.modelRegistry.authStorage.login(providerId as OAuthProvider, { onAuth: (info: { url: string; instructions?: string }) => { this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(theme.fg("dim", info.url), 1, 0)); // Use OSC 8 hyperlink escape sequence for clickable link const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`; this.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0)); diff --git a/packages/coding-agent/src/prompts/tools/ask.md b/packages/coding-agent/src/prompts/tools/ask.md index 34d8ef3b3..119312fce 100644 --- a/packages/coding-agent/src/prompts/tools/ask.md +++ b/packages/coding-agent/src/prompts/tools/ask.md @@ -8,11 +8,6 @@ Use this tool to: - Request user preferences (styling, naming conventions, architecture patterns) - Offer meaningful choices about task direction -Do NOT use for: -- Questions resolvable by reading files or docs -- Permission for normal dev tasks (just proceed) -- Decisions you should make from codebase context - Tips: - Place recommended option first with " (Recommended)" suffix - 2-5 concise, distinct options @@ -22,3 +17,14 @@ Tips: question: "Which authentication method should this API use?" options: [{"label": "JWT (Recommended)"}, {"label": "OAuth2"}, {"label": "Session cookies"}] + +## Critical: Resolve before asking + +**Exhaust all other options before asking.** Questions interrupt user flow. + +1. **Unknown file location?** → Search with grep/find first. Only ask if search fails. +2. **Ambiguous syntax/format?** → Infer from context and codebase conventions. Make a reasonable choice. +3. **Missing details?** → Check docs, related files, commit history. Fill gaps yourself. +4. **Implementation approach?** → Choose based on codebase patterns. Ask only for genuinely novel architectural decisions. + +If you can make a reasonable inference from the user's request, **do it**. Users communicate intent, not specifications—your job is to translate intent into correct implementation. diff --git a/packages/coding-agent/src/prompts/tools/calculator.md b/packages/coding-agent/src/prompts/tools/calculator.md new file mode 100644 index 000000000..f85117e12 --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/calculator.md @@ -0,0 +1,8 @@ +Basic calculations. + +Input: + - calculations: array of { expression: string, prefix: string, suffix: string } + +Notes: + - Supports +, -, *, /, %, ** and parentheses. + - Supports decimal, hex (0x), binary (0b), and octal (0o) literals.