Files
penguin-harness/packages/core/src/llm/tool-call-ids.ts
T
Yaowei Zheng 45bfae6e94 Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
2026-07-19 14:06:53 +08:00

52 lines
2.6 KiB
TypeScript

/**
* Session-level uniqueness for tool_call_id.
*
* Some providers don't produce a real call id: e.g. Gemini's functionCall has no id, so AgentHub
* uses the **function name** as the `tool_call_id` — consecutive/parallel calls to the same tool then
* all share one id. But the OmniMessage world (engine dispatch/pairing, approval routing, frontend
* tool-card attribution) keys on `tool_call_id`, and a collision lets a later call overwrite the
* earlier one (parallel same-name calls in one turn can even be dropped entirely).
*
* Approach: inbound, `EventTranslator` disambiguates duplicate ids with a `#n` suffix (the first keeps
* the original id); outbound (returning tool_result, replaying history on resume) uses
* `stripToolCallIdSuffix` to strip the suffix and restore the original — Gemini's functionResponse
* pairs by using `tool_call_id` as the name, so it must be restored to the function name. The registry
* lives at Session level (the new GenerativeModel rebuilt on compaction shares the same instance), and
* on resume `setHistory` seeds it with historical ids, so the uniqueness scope covers the entire
* context the frontend renders.
* Docs: /docs/interfaces § "The built-in implementation: GenerativeModel".
*/
export class ToolCallIdAllocator {
/** OmniMessage-level tool_call_ids already taken in this Session (history-seeded + allocated). */
private used = new Set<string>();
/** Register an already-used id (for resume seeding); registering twice is harmless. */
markUsed(id: string): void {
this.used.add(id);
}
/**
* Allocate a Session-unique id for a provider-reported tool_call_id: if unused, keep the original;
* if already used (a repeat call from a name-as-id provider), take the first free `origId#n` (n from 2).
* Providers with truly unique ids (OpenAI `call_*` / Claude `toolu_*`) never collide, so they pass through unchanged.
*/
allocate(providerId: string): string {
let id = providerId;
for (let n = 2; this.used.has(id); n += 1) {
id = `${providerId}#${n}`;
}
this.used.add(id);
return id;
}
}
/**
* Strip the `#n` suffix added by `allocate`, restoring the provider's original id (returns as-is when
* there's no suffix; idempotent). On resume there's no registry to compare against, so it trims by
* shape: real ids from known providers (OpenAI/Claude `call_*`/`toolu_*`, Gemini function names — `#`
* isn't a valid function-name char) never end in `#<digits>`, so they aren't harmed.
*/
export function stripToolCallIdSuffix(id: string): string {
return id.replace(/#\d+$/, "");
}