feat: removed normative rewrite experiment from public API and patch processing

- Removed normativeRewrite setting and buildNormativeUpdateInput() function from public API.
- Removed $normative property and TNormative generic parameter from ToolResultMessage and AgentToolResult interfaces.
- Deleted normative.ts patch normalization module and related helper functions for diff anchor processing.
- Removed rewriteAssistantToolCallArgs() and #rewriteToolCallArgs() methods that modified tool call arguments.
This commit is contained in:
can1357
2026-02-28 22:11:46 +01:00
parent dc2fde6e0f
commit e42b7bf3ce
9 changed files with 19 additions and 210 deletions
+2 -6
View File
@@ -178,19 +178,15 @@ export interface AgentState {
error?: string;
}
export interface AgentToolResult<T = any, TNormative extends TSchema = any> {
export interface AgentToolResult<T = any, _TInput = unknown> {
// Content blocks supporting text and images
content: (TextContent | ImageContent)[];
// Details to be displayed in a UI or logged
details?: T;
/** Normative input for the tool result */
$normative?: Static<TNormative>;
}
// Callback for streaming tool execution updates
export type AgentToolUpdateCallback<T = any, TNormative extends TSchema = any> = (
partialResult: AgentToolResult<T, TNormative>,
) => void;
export type AgentToolUpdateCallback<T = any, TInput = unknown> = (partialResult: AgentToolResult<T, TInput>) => void;
/** Options passed to renderResult */
export interface RenderResultOptions {
+5
View File
@@ -1,6 +1,11 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed `TInput` generic parameter from `ToolResultMessage` interface and removed `$normative` property
### Added
- `hasUnrepresentableStrictObjectMap()` pre-flight check in `tryEnforceStrictSchema`: schemas with `patternProperties` or schema-valued `additionalProperties` now degrade gracefully to non-strict mode instead of throwing during enforcement
+1 -2
View File
@@ -280,7 +280,7 @@ export interface AssistantMessage {
ttft?: number; // Time to first token in milliseconds
}
export interface ToolResultMessage<TDetails = any, TInput = unknown> {
export interface ToolResultMessage<TDetails = any> {
role: "toolResult";
toolCallId: string;
toolName: string;
@@ -290,7 +290,6 @@ export interface ToolResultMessage<TDetails = any, TInput = unknown> {
/** Timestamp when output was pruned (ms since epoch). Undefined if unpruned. */
prunedAt?: number;
timestamp: number; // Unix timestamp in milliseconds
$normative?: TInput;
}
export type Message = UserMessage | DeveloperMessage | AssistantMessage | ToolResultMessage;
+10 -6
View File
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Breaking Changes
- `ast_find` parameter `pattern` (string) replaced by `patterns` (string[])
- `ast_replace` parameters `pattern` + `rewrite` replaced by `ops: Array<{ pat: string; out: string }>`
### Added
@@ -18,17 +22,17 @@
- Web search schema exposes `max_tokens`, `temperature`, and `num_search_results` as tool parameters
- Web search provider fallback: when an explicit provider is unavailable, resolves the auto chain instead of returning empty results
### Removed
- Removed `normativeRewrite` setting that rewrote tool call arguments to normalized format in session history
- Removed `buildNormativeUpdateInput()` helper and normative patch transformation logic
### Fixed
- `ast_replace` no longer rejects empty `out` values; an empty string now deletes matched nodes
- `ast_replace` no longer trims `pat` and `out` values, preserving intentional whitespace
- `gemini_image` tool: corrected `responseModalities` values from `'Image'`/`'Text'` to uppercase `'IMAGE'`/`'TEXT'` matching the API enum
### Breaking Changes
- `ast_find` parameter `pattern` (string) replaced by `patterns` (string[])
- `ast_replace` parameters `pattern` + `rewrite` replaced by `ops: Array<{ pat: string; out: string }>`
## [13.3.14] - 2026-02-28
### Added
@@ -5389,4 +5393,4 @@ Initial public release.
- Git branch display in footer
- Message queueing during streaming responses
- OAuth integration for Gmail and Google Calendar access
- HTML export with syntax highlighting and collapsible sections
- HTML export with syntax highlighting and collapsible sections
@@ -254,15 +254,6 @@ export const SETTINGS_SCHEMA = {
submenu: true,
},
},
normativeRewrite: {
type: "boolean",
default: false,
ui: {
tab: "agent",
label: "Normative rewrite",
description: "Rewrite tool call arguments to normalized format in session history",
},
},
repeatToolDescriptions: {
type: "boolean",
default: false,
@@ -1,72 +0,0 @@
/**
* Normalize applied patch output into a canonical edit tool payload.
*/
import { generateUnifiedDiffString } from "./diff";
import { normalizeToLF, stripBom } from "./normalize";
import { parseHunks } from "./parser";
import type { PatchInput } from "./types";
export interface NormativePatchOptions {
path: string;
rename?: string;
oldContent: string;
newContent: string;
contextLines?: number;
anchor?: string | string[];
}
/** Normative patch input is the MongoDB-style update variant */
function applyAnchors(diff: string, anchors: Array<string | undefined> | undefined): string {
if (!anchors || anchors.length === 0) {
return diff;
}
const lines = diff.split("\n");
let anchorIndex = 0;
for (let i = 0; i < lines.length; i++) {
if (!lines[i].startsWith("@@")) continue;
const anchor = anchors[anchorIndex];
if (anchor !== undefined) {
lines[i] = anchor.trim().length === 0 ? "@@" : `@@ ${anchor}`;
}
anchorIndex++;
}
return lines.join("\n");
}
function deriveAnchors(diff: string): Array<string | undefined> {
const hunks = parseHunks(diff);
return hunks.map(hunk => {
if (hunk.oldLines.length === 0 || hunk.newLines.length === 0) {
return undefined;
}
const newLines = new Set(hunk.newLines);
for (const line of hunk.oldLines) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
if (!/[A-Za-z0-9_]/.test(trimmed)) continue;
if (newLines.has(line)) {
return trimmed;
}
}
return undefined;
});
}
export function buildNormativeUpdateInput(options: NormativePatchOptions): PatchInput {
const normalizedOld = normalizeToLF(stripBom(options.oldContent).text);
const normalizedNew = normalizeToLF(stripBom(options.newContent).text);
const diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew, options.contextLines ?? 3);
let anchors: Array<string | undefined> | undefined =
typeof options.anchor === "string" ? [options.anchor] : options.anchor;
if (!anchors) {
anchors = deriveAnchors(diffResult.diff);
}
const diff = applyAnchors(diffResult.diff, anchors);
return {
path: options.path,
op: "update",
rename: options.rename,
diff,
};
}
@@ -651,17 +651,12 @@ export class AgentSession {
}
if (event.message.role === "toolResult") {
const { toolName, $normative, toolCallId, details, isError, content } = event.message as {
const { toolName, details, isError, content } = event.message as {
toolName?: string;
toolCallId?: string;
details?: { path?: string; phases?: TodoPhase[] };
$normative?: Record<string, unknown>;
isError?: boolean;
content?: Array<TextContent | ImageContent>;
};
if ($normative && toolCallId && this.settings.get("normativeRewrite")) {
await this.#rewriteToolCallArgs(toolCallId, $normative);
}
// Invalidate streaming edit cache when edit tool completes to prevent stale data
if (toolName === "edit" && details?.path) {
this.#invalidateFileCacheForPath(details.path);
@@ -1259,33 +1254,6 @@ export class AgentSession {
}
}
/** Rewrite tool call arguments in agent state and persisted session history. */
async #rewriteToolCallArgs(toolCallId: string, args: Record<string, unknown>): Promise<void> {
let updated = false;
const messages = this.agent.state.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "assistant") continue;
const assistantMsg = msg as AssistantMessage;
if (!Array.isArray(assistantMsg.content)) continue;
for (const block of assistantMsg.content) {
if (typeof block !== "object" || block === null) continue;
if (!("type" in block) || (block as { type?: string }).type !== "toolCall") continue;
const toolCall = block as { id?: string; arguments?: Record<string, unknown> };
if (toolCall.id === toolCallId) {
toolCall.arguments = args;
updated = true;
break;
}
}
if (updated) break;
}
if (updated) {
await this.sessionManager.rewriteAssistantToolCallArgs(toolCallId, args);
}
}
/** Emit extension events based on session events */
async #emitExtensionEvent(event: AgentSessionEvent): Promise<void> {
if (!this.#extensionRunner) return;
@@ -1819,36 +1819,6 @@ export class SessionManager {
await this.#rewriteFile();
}
/**
* Rewrite tool call arguments in the most recent assistant message containing the toolCallId.
* Returns true if a tool call was updated.
*/
async rewriteAssistantToolCallArgs(toolCallId: string, args: Record<string, unknown>): Promise<boolean> {
let updated = false;
for (let i = this.#fileEntries.length - 1; i >= 0; i--) {
const entry = this.#fileEntries[i];
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
const message = entry.message as { content?: unknown };
if (!Array.isArray(message.content)) continue;
for (const block of message.content) {
if (typeof block !== "object" || block === null) continue;
if (!("type" in block) || (block as { type?: string }).type !== "toolCall") continue;
const toolCall = block as { id?: string; arguments?: Record<string, unknown> };
if (toolCall.id === toolCallId) {
toolCall.arguments = args;
updated = true;
break;
}
}
if (updated) break;
}
if (updated && this.persist && this.#sessionFile) {
await this.#rewriteFile();
}
return updated;
}
/**
* Append a custom message entry (for extensions) that participates in LLM context.
* @param customType Hook identifier for filtering on reload
@@ -1,52 +0,0 @@
import { describe, expect, it } from "bun:test";
import type { AssistantMessage, ToolCall } from "@oh-my-pi/pi-ai";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
function buildAssistantMessage(content: AssistantMessage["content"]): AssistantMessage {
return {
role: "assistant",
content,
api: "anthropic-messages",
provider: "anthropic",
model: "test",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1,
};
}
describe("SessionManager tool call rewrite", () => {
it("rewrites assistant toolCall arguments in context", async () => {
const session = SessionManager.inMemory();
const toolCallId = "toolu_rewrite_1";
const toolCall = {
type: "toolCall",
id: toolCallId,
name: "edit",
arguments: { path: "file.ts", op: "update", diff: "@@\n-old\n+new" },
} satisfies ToolCall;
session.appendMessage(buildAssistantMessage([toolCall]));
const updated = await session.rewriteAssistantToolCallArgs(toolCallId, {
path: "file.ts",
op: "update",
diff: "@@\n-old\n+newer",
});
expect(updated).toBe(true);
const ctx = session.buildSessionContext();
const assistant = ctx.messages.find(m => m.role === "assistant") as AssistantMessage;
const updatedCall = assistant.content.find(b => b.type === "toolCall") as ToolCall;
expect(updatedCall.arguments).toEqual({ path: "file.ts", op: "update", diff: "@@\n-old\n+newer" });
});
});