@aaroncql/pim-agent 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ToolDefinition,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { validateToolArguments } from "@earendil-works/pi-ai";
|
|
6
|
+
import type { Static, TSchema } from "typebox";
|
|
7
|
+
import { Levenshtein } from "./Levenshtein";
|
|
8
|
+
|
|
9
|
+
type Issue = { readonly path: string; readonly message: string };
|
|
10
|
+
|
|
11
|
+
type JsonSchema = {
|
|
12
|
+
readonly type?: string;
|
|
13
|
+
readonly const?: unknown;
|
|
14
|
+
readonly properties?: Readonly<Record<string, JsonSchema>>;
|
|
15
|
+
readonly items?: JsonSchema | readonly JsonSchema[];
|
|
16
|
+
readonly anyOf?: readonly JsonSchema[];
|
|
17
|
+
readonly oneOf?: readonly JsonSchema[];
|
|
18
|
+
readonly enum?: readonly unknown[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class Tools {
|
|
22
|
+
/**
|
|
23
|
+
* Wrap a tool definition so pi's validator errors get rewritten before they
|
|
24
|
+
* reach the model. Pi runs `prepareArguments` before validation, so we call
|
|
25
|
+
* pi's validator ourselves inside it, rewrite any throw, and return the
|
|
26
|
+
* (coerced) args; pi's own second validation pass then sees clean input.
|
|
27
|
+
* After successful validation we also reject unknown top-level keys, since
|
|
28
|
+
* TypeBox object schemas accept them by default and typos like
|
|
29
|
+
* `headlimit` vs `head_limit` would silently no-op.
|
|
30
|
+
*
|
|
31
|
+
* Use `Tools.register` for `pi.registerTool` callers; use `Tools.wrap` to
|
|
32
|
+
* pass into `customTools`.
|
|
33
|
+
*/
|
|
34
|
+
static wrap<TParams extends TSchema, TDetails = unknown, TState = unknown>(
|
|
35
|
+
def: ToolDefinition<TParams, TDetails, TState>
|
|
36
|
+
): ToolDefinition<TParams, TDetails, TState> {
|
|
37
|
+
const schema = def.parameters as unknown as JsonSchema;
|
|
38
|
+
return {
|
|
39
|
+
...def,
|
|
40
|
+
prepareArguments: (rawArgs: unknown): Static<TParams> => {
|
|
41
|
+
const prepared = def.prepareArguments
|
|
42
|
+
? def.prepareArguments(rawArgs)
|
|
43
|
+
: (rawArgs as Static<TParams>);
|
|
44
|
+
const cleaned = coerceQuotedEnums(prepared, schema) as Static<TParams>;
|
|
45
|
+
const strictIssues = checkStrictTypes(cleaned, schema, "");
|
|
46
|
+
if (strictIssues.length > 0) {
|
|
47
|
+
const lines = strictIssues.map((s) => ` - ${s}`).join("\n");
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Validation failed for tool "${def.name}":\n${lines}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
let validated: Static<TParams>;
|
|
53
|
+
try {
|
|
54
|
+
validated = validateToolArguments(
|
|
55
|
+
{ name: def.name, parameters: def.parameters } as never,
|
|
56
|
+
{
|
|
57
|
+
type: "toolCall",
|
|
58
|
+
id: "",
|
|
59
|
+
name: def.name,
|
|
60
|
+
arguments: cleaned as Record<string, unknown>,
|
|
61
|
+
}
|
|
62
|
+
) as Static<TParams>;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
Tools.rewriteValidationError(def.name, schema, err, cleaned)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const unknownKeys = findUnknownTopLevelKeys(schema, validated);
|
|
69
|
+
if (unknownKeys.length > 0) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
formatUnknownKeysError(def.name, schema, unknownKeys)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return validated;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static register<
|
|
80
|
+
TParams extends TSchema,
|
|
81
|
+
TDetails = unknown,
|
|
82
|
+
TState = unknown,
|
|
83
|
+
>(pi: ExtensionAPI, def: ToolDefinition<TParams, TDetails, TState>): void {
|
|
84
|
+
pi.registerTool(Tools.wrap(def));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Rewrite a `validateToolArguments` error string into a clearer form.
|
|
89
|
+
* `schema` is the tool's parameters schema, used to enumerate allowed values
|
|
90
|
+
* for `anyOf`/`enum` failures. `args` is the validated input, used to pick
|
|
91
|
+
* the matching branch of a discriminated union. Public for testing.
|
|
92
|
+
*/
|
|
93
|
+
static rewriteValidationError(
|
|
94
|
+
toolName: string,
|
|
95
|
+
schema: JsonSchema,
|
|
96
|
+
err: unknown,
|
|
97
|
+
args?: unknown
|
|
98
|
+
): string {
|
|
99
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
100
|
+
if (!message.startsWith("Validation failed for tool")) {
|
|
101
|
+
return message;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const raw = parseIssues(message);
|
|
105
|
+
const collapsed = collapseAnyOf(raw, schema, args);
|
|
106
|
+
const issues = collapsed.map((issue) => formatIssue(issue, schema));
|
|
107
|
+
|
|
108
|
+
const header = `Validation failed for tool "${toolName}":`;
|
|
109
|
+
if (issues.length === 0) {
|
|
110
|
+
return header;
|
|
111
|
+
}
|
|
112
|
+
return `${header}\n${issues.map((s) => ` - ${s}`).join("\n")}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseIssues(message: string): Issue[] {
|
|
117
|
+
const issues: Issue[] = [];
|
|
118
|
+
for (const line of message.split("\n")) {
|
|
119
|
+
if (line.startsWith("Received arguments:")) {
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
if (!line.startsWith(" - ")) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const body = line.slice(4);
|
|
126
|
+
const colonIdx = body.indexOf(": ");
|
|
127
|
+
if (colonIdx === -1) {
|
|
128
|
+
issues.push({ path: "", message: body });
|
|
129
|
+
} else {
|
|
130
|
+
issues.push({
|
|
131
|
+
path: body.slice(0, colonIdx),
|
|
132
|
+
message: body.slice(colonIdx + 2),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return issues;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Pi emits one error per anyOf branch plus a `must match a schema in anyOf`
|
|
141
|
+
* parent error, producing 6+ noisy lines for a 6-variant union. Replace the
|
|
142
|
+
* whole cluster with a single synthesised line. If the actual value has a
|
|
143
|
+
* discriminator that matches one branch, surface only that branch's real
|
|
144
|
+
* errors instead.
|
|
145
|
+
*/
|
|
146
|
+
function collapseAnyOf(
|
|
147
|
+
issues: readonly Issue[],
|
|
148
|
+
schema: JsonSchema,
|
|
149
|
+
args: unknown
|
|
150
|
+
): Issue[] {
|
|
151
|
+
const handled = new Set<number>();
|
|
152
|
+
const inserts = new Map<number, Issue[]>();
|
|
153
|
+
|
|
154
|
+
issues.forEach((issue, idx) => {
|
|
155
|
+
if (handled.has(idx) || issue.message !== "must match a schema in anyOf") {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const node = walkSchema(schema, issue.path);
|
|
159
|
+
const branches = node?.anyOf ?? node?.oneOf;
|
|
160
|
+
if (!node || !branches) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
handled.add(idx);
|
|
164
|
+
issues.forEach((other, otherIdx) => {
|
|
165
|
+
if (handled.has(otherIdx)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (other.path === issue.path || isUnderPath(other.path, issue.path)) {
|
|
169
|
+
handled.add(otherIdx);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const value = walkValue(args, issue.path);
|
|
174
|
+
const matched = matchDiscriminatedBranch(branches, value);
|
|
175
|
+
if (matched) {
|
|
176
|
+
const branchIssues = revalidateBranch(matched, value).map((sub) => ({
|
|
177
|
+
path: joinPath(issue.path, sub.path),
|
|
178
|
+
message: sub.message,
|
|
179
|
+
}));
|
|
180
|
+
inserts.set(idx, branchIssues);
|
|
181
|
+
} else {
|
|
182
|
+
inserts.set(idx, [
|
|
183
|
+
{ path: issue.path, message: describeAnyOf(branches) },
|
|
184
|
+
]);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const result: Issue[] = [];
|
|
189
|
+
issues.forEach((issue, idx) => {
|
|
190
|
+
if (inserts.has(idx)) {
|
|
191
|
+
result.push(...inserts.get(idx)!);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!handled.has(idx)) {
|
|
195
|
+
result.push(issue);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function describeAnyOf(branches: readonly JsonSchema[]): string {
|
|
202
|
+
const constValues = branches
|
|
203
|
+
.map((b) => (b && "const" in b ? b.const : undefined))
|
|
204
|
+
.filter((v) => v !== undefined);
|
|
205
|
+
if (constValues.length === branches.length) {
|
|
206
|
+
return `must be one of: ${constValues.map(displayValue).join(", ")}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const discriminator = findDiscriminator(branches);
|
|
210
|
+
if (discriminator) {
|
|
211
|
+
const values = discriminator.values.map(displayValue).join(", ");
|
|
212
|
+
return `must match one of the allowed variants (${discriminator.field}: ${values})`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return `must match one of ${branches.length} allowed variants`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Format an enum value for an error message. Strings render bare so a weaker
|
|
220
|
+
* model that retries off the message doesn't include the quotes in its next
|
|
221
|
+
* attempt (e.g. `action: "\"create\""`). Non-strings keep JSON form for
|
|
222
|
+
* disambiguation.
|
|
223
|
+
*/
|
|
224
|
+
function displayValue(value: unknown): string {
|
|
225
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function matchDiscriminatedBranch(
|
|
229
|
+
branches: readonly JsonSchema[],
|
|
230
|
+
value: unknown
|
|
231
|
+
): JsonSchema | undefined {
|
|
232
|
+
if (!isRecord(value)) {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
const discriminator = findDiscriminator(branches);
|
|
236
|
+
if (!discriminator) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
const actual = value[discriminator.field];
|
|
240
|
+
const branchIndex = discriminator.values.findIndex(
|
|
241
|
+
(v) => JSON.stringify(v) === JSON.stringify(actual)
|
|
242
|
+
);
|
|
243
|
+
return branchIndex >= 0 ? branches[branchIndex] : undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function findDiscriminator(
|
|
247
|
+
branches: readonly JsonSchema[]
|
|
248
|
+
): { readonly field: string; readonly values: readonly unknown[] } | undefined {
|
|
249
|
+
const objectBranches = branches.filter(
|
|
250
|
+
(b) => b.type === "object" && b.properties
|
|
251
|
+
);
|
|
252
|
+
if (
|
|
253
|
+
objectBranches.length !== branches.length ||
|
|
254
|
+
objectBranches.length === 0
|
|
255
|
+
) {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
for (const propName of Object.keys(objectBranches[0]!.properties!)) {
|
|
259
|
+
const values: unknown[] = [];
|
|
260
|
+
for (const branch of objectBranches) {
|
|
261
|
+
const prop = branch.properties![propName];
|
|
262
|
+
if (prop && "const" in prop) {
|
|
263
|
+
values.push(prop.const);
|
|
264
|
+
} else {
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (
|
|
269
|
+
values.length === objectBranches.length &&
|
|
270
|
+
new Set(values.map((v) => JSON.stringify(v))).size === values.length
|
|
271
|
+
) {
|
|
272
|
+
return { field: propName, values };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function revalidateBranch(branch: JsonSchema, value: unknown): Issue[] {
|
|
279
|
+
try {
|
|
280
|
+
validateToolArguments(
|
|
281
|
+
{ name: "_branch", parameters: branch as TSchema } as never,
|
|
282
|
+
{
|
|
283
|
+
type: "toolCall",
|
|
284
|
+
id: "",
|
|
285
|
+
name: "_branch",
|
|
286
|
+
arguments: (value ?? {}) as Record<string, unknown>,
|
|
287
|
+
}
|
|
288
|
+
);
|
|
289
|
+
return [];
|
|
290
|
+
} catch (err) {
|
|
291
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
292
|
+
return parseIssues(message);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function walkSchema(
|
|
297
|
+
schema: JsonSchema | undefined,
|
|
298
|
+
path: string
|
|
299
|
+
): JsonSchema | undefined {
|
|
300
|
+
if (!schema) {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
if (!path) {
|
|
304
|
+
return schema;
|
|
305
|
+
}
|
|
306
|
+
let current: JsonSchema | undefined = schema;
|
|
307
|
+
for (const part of path.split(".")) {
|
|
308
|
+
if (!current) {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
if (current.properties && part in current.properties) {
|
|
312
|
+
current = current.properties[part];
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (current.items) {
|
|
316
|
+
current = Array.isArray(current.items)
|
|
317
|
+
? current.items[Number(part)]
|
|
318
|
+
: current.items;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
return current;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function walkValue(value: unknown, path: string): unknown {
|
|
327
|
+
if (!path) {
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
let current = value;
|
|
331
|
+
for (const part of path.split(".")) {
|
|
332
|
+
if (current === null || current === undefined) {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
if (Array.isArray(current)) {
|
|
336
|
+
current = current[Number(part)];
|
|
337
|
+
} else if (isRecord(current)) {
|
|
338
|
+
current = current[part];
|
|
339
|
+
} else {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return current;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function isUnderPath(candidate: string, parent: string): boolean {
|
|
347
|
+
if (!parent) {
|
|
348
|
+
return candidate.length > 0;
|
|
349
|
+
}
|
|
350
|
+
return candidate.startsWith(`${parent}.`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function joinPath(parent: string, child: string): string {
|
|
354
|
+
if (!parent) {
|
|
355
|
+
return child;
|
|
356
|
+
}
|
|
357
|
+
if (!child) {
|
|
358
|
+
return parent;
|
|
359
|
+
}
|
|
360
|
+
return `${parent}.${child}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
364
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function formatIssue(issue: Issue, schema: JsonSchema): string {
|
|
368
|
+
const requiredMatch = issue.message.match(
|
|
369
|
+
/^must have required propert(?:y|ies) (.+)$/
|
|
370
|
+
);
|
|
371
|
+
if (requiredMatch) {
|
|
372
|
+
const props = requiredMatch[1]!;
|
|
373
|
+
const parent = issue.path.includes(".")
|
|
374
|
+
? issue.path.slice(0, issue.path.lastIndexOf("."))
|
|
375
|
+
: "";
|
|
376
|
+
const where = parent ? ` at ${parent}` : "";
|
|
377
|
+
const noun = props.includes(",") ? "properties" : "property";
|
|
378
|
+
return `missing required ${noun}${where}: ${props}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (issue.message === "must be equal to one of the allowed values") {
|
|
382
|
+
const node = walkSchema(schema, issue.path);
|
|
383
|
+
if (node?.enum && node.enum.length > 0) {
|
|
384
|
+
const values = node.enum.map(displayValue).join(", ");
|
|
385
|
+
return `${issue.path}: must be one of: ${values}`;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (!issue.path) {
|
|
390
|
+
return issue.message;
|
|
391
|
+
}
|
|
392
|
+
return `${issue.path}: ${issue.message}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Recursively unwrap quoted enum values. Weaker models sometimes send
|
|
397
|
+
* `"\"create\""` instead of `"create"` because the JSON Schema and earlier
|
|
398
|
+
* error messages show enum values quoted. Only unwraps when the inner value is
|
|
399
|
+
* a valid enum/const match, so real typos still surface as errors.
|
|
400
|
+
*/
|
|
401
|
+
function coerceQuotedEnums(
|
|
402
|
+
value: unknown,
|
|
403
|
+
schema: JsonSchema | undefined
|
|
404
|
+
): unknown {
|
|
405
|
+
if (!schema) {
|
|
406
|
+
return value;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (typeof value === "string") {
|
|
410
|
+
const allowed = collectAllowedStrings(schema);
|
|
411
|
+
if (allowed && allowed.length > 0 && !allowed.includes(value)) {
|
|
412
|
+
const unwrapped = stripWrappingQuotes(value);
|
|
413
|
+
if (unwrapped !== value && allowed.includes(unwrapped)) {
|
|
414
|
+
return unwrapped;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return value;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (isRecord(value)) {
|
|
421
|
+
let mutated: Record<string, unknown> | undefined;
|
|
422
|
+
const propSchemas = schema.properties;
|
|
423
|
+
for (const key of Object.keys(value)) {
|
|
424
|
+
const subSchema = propSchemas?.[key];
|
|
425
|
+
const next = coerceQuotedEnums(value[key], subSchema);
|
|
426
|
+
if (next !== value[key]) {
|
|
427
|
+
mutated ??= { ...value };
|
|
428
|
+
mutated[key] = next;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (schema.anyOf || schema.oneOf) {
|
|
432
|
+
const branches = (schema.anyOf ?? schema.oneOf) as readonly JsonSchema[];
|
|
433
|
+
const branch =
|
|
434
|
+
matchDiscriminatedBranch(branches, mutated ?? value) ??
|
|
435
|
+
branches.find((b) => b.type === "object" && b.properties);
|
|
436
|
+
if (branch) {
|
|
437
|
+
const recursed = coerceQuotedEnums(mutated ?? value, branch);
|
|
438
|
+
if (recursed !== (mutated ?? value)) {
|
|
439
|
+
return recursed;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return mutated ?? value;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (Array.isArray(value)) {
|
|
447
|
+
const itemsField = schema.items;
|
|
448
|
+
if (!itemsField || Array.isArray(itemsField)) {
|
|
449
|
+
return value;
|
|
450
|
+
}
|
|
451
|
+
const itemSchema = itemsField as JsonSchema;
|
|
452
|
+
let mutated: unknown[] | undefined;
|
|
453
|
+
for (let i = 0; i < value.length; i++) {
|
|
454
|
+
const next = coerceQuotedEnums(value[i], itemSchema);
|
|
455
|
+
if (next !== value[i]) {
|
|
456
|
+
mutated ??= [...value];
|
|
457
|
+
mutated[i] = next;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return mutated ?? value;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return value;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function collectAllowedStrings(schema: JsonSchema): string[] | undefined {
|
|
467
|
+
if (schema.enum) {
|
|
468
|
+
const strings = schema.enum.filter(
|
|
469
|
+
(v): v is string => typeof v === "string"
|
|
470
|
+
);
|
|
471
|
+
return strings.length > 0 ? strings : undefined;
|
|
472
|
+
}
|
|
473
|
+
if (typeof schema.const === "string") {
|
|
474
|
+
return [schema.const];
|
|
475
|
+
}
|
|
476
|
+
const branches = schema.anyOf ?? schema.oneOf;
|
|
477
|
+
if (branches) {
|
|
478
|
+
const collected: string[] = [];
|
|
479
|
+
for (const branch of branches) {
|
|
480
|
+
const inner = collectAllowedStrings(branch);
|
|
481
|
+
if (inner) {
|
|
482
|
+
collected.push(...inner);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return collected.length > 0 ? collected : undefined;
|
|
486
|
+
}
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function stripWrappingQuotes(value: string): string {
|
|
491
|
+
if (value.length < 2) {
|
|
492
|
+
return value;
|
|
493
|
+
}
|
|
494
|
+
const first = value[0]!;
|
|
495
|
+
const last = value[value.length - 1]!;
|
|
496
|
+
const quoteChars = ['"', "'", "`"];
|
|
497
|
+
if (quoteChars.includes(first) && first === last) {
|
|
498
|
+
return value.slice(1, -1);
|
|
499
|
+
}
|
|
500
|
+
return value;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Pi-ai intentionally coerces a lot of LLM-quirk inputs (`"42"` → 42,
|
|
505
|
+
* `"true"` → true, single value → array, etc.) so weak/cheap models don't
|
|
506
|
+
* fail on JSON shakiness. This is good. But two of those coercions are
|
|
507
|
+
* almost certainly silent bugs:
|
|
508
|
+
*
|
|
509
|
+
* - `null` → `0` / `""` / `false` / `"null"` for primitive fields. `null`
|
|
510
|
+
* is never a sensible value for a non-nullable primitive; treating it as
|
|
511
|
+
* the type's zero value hides the model's confusion.
|
|
512
|
+
* - `"42.5"` → `42` for an integer field. The float-shaped string means the
|
|
513
|
+
* model misunderstood the type; truncating loses information without
|
|
514
|
+
* recovering the intent.
|
|
515
|
+
*
|
|
516
|
+
* Reject both before pi's `Value.Convert` runs.
|
|
517
|
+
*/
|
|
518
|
+
function checkStrictTypes(
|
|
519
|
+
value: unknown,
|
|
520
|
+
schema: JsonSchema | undefined,
|
|
521
|
+
path: string
|
|
522
|
+
): string[] {
|
|
523
|
+
if (!schema) {
|
|
524
|
+
return [];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const types = collectSchemaTypes(schema);
|
|
528
|
+
if (types.length > 0 && !types.includes("null") && value === null) {
|
|
529
|
+
return [
|
|
530
|
+
`${path || "root"}: must not be null (expected ${types.join(" | ")})`,
|
|
531
|
+
];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (
|
|
535
|
+
types.includes("integer") &&
|
|
536
|
+
typeof value === "string" &&
|
|
537
|
+
/^-?\d+\.\d*[1-9]/.test(value)
|
|
538
|
+
) {
|
|
539
|
+
return [
|
|
540
|
+
`${path || "root"}: must be an integer (received "${value}" — fractional part would be truncated)`,
|
|
541
|
+
];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (isRecord(value) && schema.properties) {
|
|
545
|
+
const issues: string[] = [];
|
|
546
|
+
for (const [key, sub] of Object.entries(value)) {
|
|
547
|
+
const subSchema = schema.properties[key];
|
|
548
|
+
if (subSchema) {
|
|
549
|
+
issues.push(...checkStrictTypes(sub, subSchema, joinPath(path, key)));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return issues;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (Array.isArray(value)) {
|
|
556
|
+
const itemsField = schema.items;
|
|
557
|
+
if (!itemsField || Array.isArray(itemsField)) {
|
|
558
|
+
return [];
|
|
559
|
+
}
|
|
560
|
+
const itemSchema = itemsField as JsonSchema;
|
|
561
|
+
const issues: string[] = [];
|
|
562
|
+
for (let i = 0; i < value.length; i++) {
|
|
563
|
+
issues.push(
|
|
564
|
+
...checkStrictTypes(value[i], itemSchema, joinPath(path, String(i)))
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
return issues;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return [];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function collectSchemaTypes(schema: JsonSchema): string[] {
|
|
574
|
+
const types = new Set<string>();
|
|
575
|
+
if (typeof schema.type === "string") {
|
|
576
|
+
types.add(schema.type);
|
|
577
|
+
}
|
|
578
|
+
if (Array.isArray(schema.type)) {
|
|
579
|
+
for (const t of schema.type) {
|
|
580
|
+
if (typeof t === "string") {
|
|
581
|
+
types.add(t);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const branches = schema.anyOf ?? schema.oneOf;
|
|
586
|
+
if (branches) {
|
|
587
|
+
for (const b of branches) {
|
|
588
|
+
for (const t of collectSchemaTypes(b)) {
|
|
589
|
+
types.add(t);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return Array.from(types);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function findUnknownTopLevelKeys(schema: JsonSchema, args: unknown): string[] {
|
|
597
|
+
if (schema.type !== "object" || !schema.properties || !isRecord(args)) {
|
|
598
|
+
return [];
|
|
599
|
+
}
|
|
600
|
+
const known = new Set(Object.keys(schema.properties));
|
|
601
|
+
return Object.keys(args).filter((key) => !known.has(key));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function formatUnknownKeysError(
|
|
605
|
+
toolName: string,
|
|
606
|
+
schema: JsonSchema,
|
|
607
|
+
unknownKeys: readonly string[]
|
|
608
|
+
): string {
|
|
609
|
+
const known = schema.properties ? Object.keys(schema.properties) : [];
|
|
610
|
+
const lines = unknownKeys.map((key) => {
|
|
611
|
+
const suggestion = closestKey(key, known);
|
|
612
|
+
const hint = suggestion ? ` (did you mean "${suggestion}"?)` : "";
|
|
613
|
+
return ` - unknown property: ${key}${hint}`;
|
|
614
|
+
});
|
|
615
|
+
return `Validation failed for tool "${toolName}":\n${lines.join("\n")}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function closestKey(
|
|
619
|
+
key: string,
|
|
620
|
+
candidates: readonly string[]
|
|
621
|
+
): string | undefined {
|
|
622
|
+
const lowered = key.toLowerCase();
|
|
623
|
+
let best: { key: string; distance: number } | undefined;
|
|
624
|
+
for (const candidate of candidates) {
|
|
625
|
+
if (candidate.toLowerCase() === lowered) {
|
|
626
|
+
return candidate;
|
|
627
|
+
}
|
|
628
|
+
const d = Levenshtein.distance(lowered, candidate.toLowerCase());
|
|
629
|
+
if (d <= Math.max(2, Math.floor(candidate.length / 3))) {
|
|
630
|
+
if (!best || d < best.distance) {
|
|
631
|
+
best = { key: candidate, distance: d };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return best?.key;
|
|
636
|
+
}
|