@mikode13/harness 2.2.0 → 2.3.0
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/dist/engines/domain/model/llmAgent.d.ts +1 -1
- package/dist/engines/domain/model/llmAgent.js.map +1 -1
- package/dist/llm/domain/tool.d.ts +1 -8
- package/dist/llm/domain/tool.js.map +1 -1
- package/dist/tools/domain/tool.d.ts +9 -0
- package/dist/tools/domain/tool.js +2 -0
- package/dist/tools/domain/tool.js.map +1 -0
- package/dist/tools/domain/workspace.d.ts +48 -0
- package/dist/tools/domain/workspace.js +2 -0
- package/dist/tools/domain/workspace.js.map +1 -0
- package/dist/tools/infrastructure/boundedWorkspace.d.ts +66 -0
- package/dist/tools/infrastructure/boundedWorkspace.js +160 -0
- package/dist/tools/infrastructure/boundedWorkspace.js.map +1 -0
- package/dist/tools/infrastructure/createWorkspace.d.ts +14 -0
- package/dist/tools/infrastructure/createWorkspace.js +54 -0
- package/dist/tools/infrastructure/createWorkspace.js.map +1 -0
- package/dist/tools/infrastructure/defineTool.d.ts +15 -0
- package/dist/tools/infrastructure/defineTool.js +87 -0
- package/dist/tools/infrastructure/defineTool.js.map +1 -0
- package/dist/tools/infrastructure/gitWorkspace.d.ts +13 -0
- package/dist/tools/infrastructure/gitWorkspace.js +61 -0
- package/dist/tools/infrastructure/gitWorkspace.js.map +1 -0
- package/dist/tools/infrastructure/ripgrepWorkspace.d.ts +20 -0
- package/dist/tools/infrastructure/ripgrepWorkspace.js +85 -0
- package/dist/tools/infrastructure/ripgrepWorkspace.js.map +1 -0
- package/dist/tools/infrastructure/runProcess.d.ts +23 -0
- package/dist/tools/infrastructure/runProcess.js +91 -0
- package/dist/tools/infrastructure/runProcess.js.map +1 -0
- package/dist/tools/infrastructure/workspaceTools.d.ts +4 -0
- package/dist/tools/infrastructure/workspaceTools.js +110 -0
- package/dist/tools/infrastructure/workspaceTools.js.map +1 -0
- package/package.json +2 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Agent, AgentResponse, Callback } from '#src/agent/domain/agent';
|
|
2
2
|
import type { LLMClient } from '#src/llm/domain/llm';
|
|
3
3
|
import type { Message } from '#src/llm/domain/message';
|
|
4
|
-
import type { Tool } from '#src/
|
|
4
|
+
import type { Tool } from '#src/tools/domain/tool';
|
|
5
5
|
/**
|
|
6
6
|
* An agent whose conversation is owned by MiKode: every run sends the whole context to a
|
|
7
7
|
* stateless `LLMClient`. Within a run the model may call the agent's tools; the agent runs them
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"llmAgent.js","sourceRoot":"","sources":["../../../../src/engines/domain/model/llmAgent.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Agent, AgentResponse, Callback, ProgressEvent } from '#src/agent/domain/agent';\nimport {\n\tInvalidAgentConfigError,\n\tRecoverableError,\n\tUnrecoverableError,\n\twithSpentTokens,\n} from '#src/agent/domain/errors';\nimport {\n\tclassifyHostFailure,\n\tclassifyProviderFailure,\n\tdescribeFailure,\n\ttreatErrors,\n} from '#src/agent/domain/providerFailure';\nimport { Conversation } from '#src/llm/domain/conversation';\nimport type { LLMClient, LLMResponse } from '#src/llm/domain/llm';\nimport type { Message, MessagePart, ToolCallPart, ToolResultPart } from '#src/llm/domain/message';\nimport type { Tool, ToolDefinition } from '#src/llm/domain/tool';\nimport { addTokens, type Tokens } from '#src/shared/domain/tokens';\n\nfunction describePart(part: MessagePart): ProgressEvent | undefined {\n\tswitch (part.type) {\n\t\tcase 'text':\n\t\t\treturn { type: 'agentMessage', message: part.text };\n\t\tcase 'reasoning':\n\t\t\treturn { type: 'reasoning', message: part.text };\n\t\tcase 'toolCall':\n\t\t\treturn { type: 'tool', id: part.id, name: part.name, status: 'in_progress' };\n\t\tcase 'toolResult':\n\t\t\treturn {\n\t\t\t\ttype: 'tool',\n\t\t\t\tid: part.callId,\n\t\t\t\tname: part.name,\n\t\t\t\tstatus: part.isError ? 'error' : 'completed',\n\t\t\t};\n\t\tcase 'providerData':\n\t\t\t// Opaque by design: its readable side, if any, arrives as a reasoning part.\n\t\t\treturn undefined;\n\t}\n}\n\nfunction narrate(part: MessagePart, callback: Callback): void {\n\tconst event = describePart(part);\n\tif (!event) {\n\t\treturn;\n\t}\n\n\ttreatErrors(\n\t\t() => {\n\t\t\tcallback(event);\n\t\t},\n\t\tclassifyHostFailure,\n\t\t'LLM agent progress callback failed',\n\t);\n}\n\n/**\n * An agent whose conversation is owned by MiKode: every run sends the whole context to a\n * stateless `LLMClient`. Within a run the model may call the agent's tools; the agent runs them\n * and sends their results back until the model answers without calling one. Turn boundaries\n * are the consumer's to emit, as with the other engines; this agent only narrates what the\n * model produced and what its tools did.\n */\nexport class LLMAgent implements Agent {\n\tprivate readonly llmClient: LLMClient;\n\tprivate readonly conversation: Conversation;\n\tprivate readonly tools: Map<string, Tool>;\n\tprivate readonly toolDefinitions: ToolDefinition[];\n\tprivate readonly maxSteps: number;\n\n\tconstructor({\n\t\tllmClient,\n\t\tmessages,\n\t\ttools = [],\n\t\tmaxSteps = 25,\n\t}: {\n\t\tllmClient: LLMClient;\n\t\tmessages?: Message[];\n\t\ttools?: Tool[];\n\t\t/** How many calls to the model one run may make before it fails. */\n\t\tmaxSteps?: number;\n\t}) {\n\t\tif (!Number.isInteger(maxSteps) || maxSteps < 1) {\n\t\t\tthrow new InvalidAgentConfigError(\n\t\t\t\t`maxSteps must be a positive integer; got ${String(maxSteps)}`,\n\t\t\t);\n\t\t}\n\n\t\tthis.llmClient = llmClient;\n\t\tthis.conversation = new Conversation(messages);\n\t\tthis.maxSteps = maxSteps;\n\t\tthis.tools = new Map(tools.map(tool => [tool.name, tool]));\n\t\tif (this.tools.size !== tools.length) {\n\t\t\t// The model calls tools by name, so a second one with the same name could never run.\n\t\t\tthrow new InvalidAgentConfigError('Two tools share a name; each name must be unique');\n\t\t}\n\t\t// The client only describes the tools to the model; running them stays with the agent.\n\t\tthis.toolDefinitions = tools.map(({ name, description, inputSchema }) => ({\n\t\t\tname,\n\t\t\tdescription,\n\t\t\tinputSchema,\n\t\t}));\n\t}\n\n\tprivate async llmCall(runMessages: Message[], signal: AbortSignal): Promise<LLMResponse> {\n\t\tlet response: LLMResponse;\n\t\ttry {\n\t\t\t// Sent as a copy, so what the run records is what the agent built, whatever the client does.\n\t\t\tresponse = await this.llmClient.send(\n\t\t\t\t{\n\t\t\t\t\tcontext: [...this.conversation.getContext(), ...structuredClone(runMessages)],\n\t\t\t\t\ttools: this.toolDefinitions,\n\t\t\t\t},\n\t\t\t\tsignal,\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tthrow classifyProviderFailure(error, 'The LLM call failed');\n\t\t}\n\n\t\tif (response.stopReason !== 'completed') {\n\t\t\tthrow new UnrecoverableError('The LLM stopped before completing its answer', {\n\t\t\t\tcause: `The model stopped with \"${response.stopReason}\".`,\n\t\t\t\t// The call was billed even though its answer is unusable.\n\t\t\t\ttokens: response.usage,\n\t\t\t\tusageUnreported: !response.usage,\n\t\t\t});\n\t\t}\n\n\t\treturn response;\n\t}\n\n\t/**\n\t * Always answers the call: a missing tool or a failing one becomes an error result, so the\n\t * model can correct itself. Only a cancellation escapes, whatever error the tool turned it into.\n\t */\n\tprivate async runTool(call: ToolCallPart, signal: AbortSignal): Promise<ToolResultPart> {\n\t\tconst result = { type: 'toolResult' as const, callId: call.id, name: call.name };\n\t\tconst tool = this.tools.get(call.name);\n\t\tif (!tool) {\n\t\t\treturn {\n\t\t\t\t...result,\n\t\t\t\toutput: `There is no tool named \"${call.name}\". Available tools: ${[...this.tools.keys()].join(', ') || 'none'}.`,\n\t\t\t\tisError: true,\n\t\t\t};\n\t\t}\n\n\t\t// Checked again at the last moment: the consumer may cancel on the announcement itself.\n\t\tsignal.throwIfAborted();\n\t\ttry {\n\t\t\treturn { ...result, output: await tool.execute(call.input, signal), isError: false };\n\t\t} catch (error) {\n\t\t\tsignal.throwIfAborted();\n\t\t\treturn { ...result, output: describeFailure(error), isError: true };\n\t\t}\n\t}\n\n\t/** One at a time and in order, so their effects and their narration never interleave. */\n\tprivate async runTools(\n\t\tcalls: ToolCallPart[],\n\t\tsignal: AbortSignal,\n\t\tcallback: Callback,\n\t): Promise<Message> {\n\t\tconst results: MessagePart[] = [];\n\t\tfor (const call of calls) {\n\t\t\t// A call before this one may have ignored the cancellation; this one must not start,\n\t\t\t// nor be announced as running.\n\t\t\tsignal.throwIfAborted();\n\t\t\t// Announced only as its turn comes, so a consumer never shows a call as running early.\n\t\t\tnarrate(call, callback);\n\t\t\tconst result = await this.runTool(call, signal);\n\t\t\tnarrate(result, callback);\n\t\t\tresults.push(result);\n\t\t}\n\n\t\treturn { role: 'tool', content: results };\n\t}\n\n\tasync run(prompt: string, signal: AbortSignal, callback: Callback): Promise<AgentResponse> {\n\t\tconst start = Date.now();\n\t\tconst runMessages: Message[] = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];\n\t\tlet tokens: Tokens | undefined;\n\t\t// Once one call went unreported the run's total is unknown, however many report later.\n\t\tlet unreported = false;\n\t\tlet toolRan = false;\n\n\t\ttry {\n\t\t\tfor (let step = 1; step <= this.maxSteps; step++) {\n\t\t\t\tconst response = await this.llmCall(runMessages, signal);\n\t\t\t\ttokens = addTokens(tokens, response.usage);\n\t\t\t\tunreported ||= !response.usage;\n\t\t\t\trunMessages.push(response.message);\n\n\t\t\t\t// Tool calls are narrated as they start, in `runTools`, and never if they do not run.\n\t\t\t\tfor (const part of response.message.content) {\n\t\t\t\t\tif (part.type !== 'toolCall') narrate(part, callback);\n\t\t\t\t}\n\n\t\t\t\tconst calls = response.message.content.filter(part => part.type === 'toolCall');\n\t\t\t\tif (calls.length === 0) {\n\t\t\t\t\t// Recorded only now, so a failed run leaves no tool call without its result.\n\t\t\t\t\tthis.conversation.addRun(runMessages);\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\t// Only the final answer: text from earlier steps was narrated as it came.\n\t\t\t\t\t\tresponse: response.message.content\n\t\t\t\t\t\t\t.filter(part => part.type === 'text')\n\t\t\t\t\t\t\t.map(part => part.text)\n\t\t\t\t\t\t\t.join('\\n'),\n\t\t\t\t\t\ttokens: unreported ? undefined : tokens,\n\t\t\t\t\t\tduration: (Date.now() - start) / 1000,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\t// On the last step no call is left to send the results to, so the tools do not run.\n\t\t\t\tif (step < this.maxSteps) {\n\t\t\t\t\t// Only a call to a tool the agent has reaches `execute`; a missing one has no\n\t\t\t\t\t// effect a retry could repeat.\n\t\t\t\t\ttoolRan ||= calls.some(call => this.tools.has(call.name));\n\t\t\t\t\trunMessages.push(await this.runTools(calls, signal, callback));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthrow new UnrecoverableError('The LLM did not finish within its step limit', {\n\t\t\t\tcause: `The model was still calling tools after ${String(this.maxSteps)} steps.`,\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tlet failure = error;\n\t\t\tif (toolRan && error instanceof RecoverableError) {\n\t\t\t\t// RetryingAgent would run the whole prompt again, and with it every tool this run\n\t\t\t\t// already executed, so a failure after one ran must not invite a retry.\n\t\t\t\tconst unrecoverable = new UnrecoverableError(error.message, {\n\t\t\t\t\tcause: error.cause,\n\t\t\t\t\ttokens: error.tokens,\n\t\t\t\t\tusageUnreported: error.usageUnreported,\n\t\t\t\t});\n\t\t\t\tunrecoverable.stack = error.stack;\n\t\t\t\tfailure = unrecoverable;\n\t\t\t}\n\n\t\t\t// The run spent the tokens of every step before the one that failed.\n\t\t\tthrow withSpentTokens(failure, tokens, unreported);\n\t\t}\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"llmAgent.js","sourceRoot":"","sources":["../../../../src/engines/domain/model/llmAgent.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Agent, AgentResponse, Callback, ProgressEvent } from '#src/agent/domain/agent';\nimport {\n\tInvalidAgentConfigError,\n\tRecoverableError,\n\tUnrecoverableError,\n\twithSpentTokens,\n} from '#src/agent/domain/errors';\nimport {\n\tclassifyHostFailure,\n\tclassifyProviderFailure,\n\tdescribeFailure,\n\ttreatErrors,\n} from '#src/agent/domain/providerFailure';\nimport { Conversation } from '#src/llm/domain/conversation';\nimport type { LLMClient, LLMResponse } from '#src/llm/domain/llm';\nimport type { Message, MessagePart, ToolCallPart, ToolResultPart } from '#src/llm/domain/message';\nimport type { ToolDefinition } from '#src/llm/domain/tool';\nimport type { Tool } from '#src/tools/domain/tool';\nimport { addTokens, type Tokens } from '#src/shared/domain/tokens';\n\nfunction describePart(part: MessagePart): ProgressEvent | undefined {\n\tswitch (part.type) {\n\t\tcase 'text':\n\t\t\treturn { type: 'agentMessage', message: part.text };\n\t\tcase 'reasoning':\n\t\t\treturn { type: 'reasoning', message: part.text };\n\t\tcase 'toolCall':\n\t\t\treturn { type: 'tool', id: part.id, name: part.name, status: 'in_progress' };\n\t\tcase 'toolResult':\n\t\t\treturn {\n\t\t\t\ttype: 'tool',\n\t\t\t\tid: part.callId,\n\t\t\t\tname: part.name,\n\t\t\t\tstatus: part.isError ? 'error' : 'completed',\n\t\t\t};\n\t\tcase 'providerData':\n\t\t\t// Opaque by design: its readable side, if any, arrives as a reasoning part.\n\t\t\treturn undefined;\n\t}\n}\n\nfunction narrate(part: MessagePart, callback: Callback): void {\n\tconst event = describePart(part);\n\tif (!event) {\n\t\treturn;\n\t}\n\n\ttreatErrors(\n\t\t() => {\n\t\t\tcallback(event);\n\t\t},\n\t\tclassifyHostFailure,\n\t\t'LLM agent progress callback failed',\n\t);\n}\n\n/**\n * An agent whose conversation is owned by MiKode: every run sends the whole context to a\n * stateless `LLMClient`. Within a run the model may call the agent's tools; the agent runs them\n * and sends their results back until the model answers without calling one. Turn boundaries\n * are the consumer's to emit, as with the other engines; this agent only narrates what the\n * model produced and what its tools did.\n */\nexport class LLMAgent implements Agent {\n\tprivate readonly llmClient: LLMClient;\n\tprivate readonly conversation: Conversation;\n\tprivate readonly tools: Map<string, Tool>;\n\tprivate readonly toolDefinitions: ToolDefinition[];\n\tprivate readonly maxSteps: number;\n\n\tconstructor({\n\t\tllmClient,\n\t\tmessages,\n\t\ttools = [],\n\t\tmaxSteps = 25,\n\t}: {\n\t\tllmClient: LLMClient;\n\t\tmessages?: Message[];\n\t\ttools?: Tool[];\n\t\t/** How many calls to the model one run may make before it fails. */\n\t\tmaxSteps?: number;\n\t}) {\n\t\tif (!Number.isInteger(maxSteps) || maxSteps < 1) {\n\t\t\tthrow new InvalidAgentConfigError(\n\t\t\t\t`maxSteps must be a positive integer; got ${String(maxSteps)}`,\n\t\t\t);\n\t\t}\n\n\t\tthis.llmClient = llmClient;\n\t\tthis.conversation = new Conversation(messages);\n\t\tthis.maxSteps = maxSteps;\n\t\tthis.tools = new Map(tools.map(tool => [tool.name, tool]));\n\t\tif (this.tools.size !== tools.length) {\n\t\t\t// The model calls tools by name, so a second one with the same name could never run.\n\t\t\tthrow new InvalidAgentConfigError('Two tools share a name; each name must be unique');\n\t\t}\n\t\t// The client only describes the tools to the model; running them stays with the agent.\n\t\tthis.toolDefinitions = tools.map(({ name, description, inputSchema }) => ({\n\t\t\tname,\n\t\t\tdescription,\n\t\t\tinputSchema,\n\t\t}));\n\t}\n\n\tprivate async llmCall(runMessages: Message[], signal: AbortSignal): Promise<LLMResponse> {\n\t\tlet response: LLMResponse;\n\t\ttry {\n\t\t\t// Sent as a copy, so what the run records is what the agent built, whatever the client does.\n\t\t\tresponse = await this.llmClient.send(\n\t\t\t\t{\n\t\t\t\t\tcontext: [...this.conversation.getContext(), ...structuredClone(runMessages)],\n\t\t\t\t\ttools: this.toolDefinitions,\n\t\t\t\t},\n\t\t\t\tsignal,\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tthrow classifyProviderFailure(error, 'The LLM call failed');\n\t\t}\n\n\t\tif (response.stopReason !== 'completed') {\n\t\t\tthrow new UnrecoverableError('The LLM stopped before completing its answer', {\n\t\t\t\tcause: `The model stopped with \"${response.stopReason}\".`,\n\t\t\t\t// The call was billed even though its answer is unusable.\n\t\t\t\ttokens: response.usage,\n\t\t\t\tusageUnreported: !response.usage,\n\t\t\t});\n\t\t}\n\n\t\treturn response;\n\t}\n\n\t/**\n\t * Always answers the call: a missing tool or a failing one becomes an error result, so the\n\t * model can correct itself. Only a cancellation escapes, whatever error the tool turned it into.\n\t */\n\tprivate async runTool(call: ToolCallPart, signal: AbortSignal): Promise<ToolResultPart> {\n\t\tconst result = { type: 'toolResult' as const, callId: call.id, name: call.name };\n\t\tconst tool = this.tools.get(call.name);\n\t\tif (!tool) {\n\t\t\treturn {\n\t\t\t\t...result,\n\t\t\t\toutput: `There is no tool named \"${call.name}\". Available tools: ${[...this.tools.keys()].join(', ') || 'none'}.`,\n\t\t\t\tisError: true,\n\t\t\t};\n\t\t}\n\n\t\t// Checked again at the last moment: the consumer may cancel on the announcement itself.\n\t\tsignal.throwIfAborted();\n\t\ttry {\n\t\t\treturn { ...result, output: await tool.execute(call.input, signal), isError: false };\n\t\t} catch (error) {\n\t\t\tsignal.throwIfAborted();\n\t\t\treturn { ...result, output: describeFailure(error), isError: true };\n\t\t}\n\t}\n\n\t/** One at a time and in order, so their effects and their narration never interleave. */\n\tprivate async runTools(\n\t\tcalls: ToolCallPart[],\n\t\tsignal: AbortSignal,\n\t\tcallback: Callback,\n\t): Promise<Message> {\n\t\tconst results: MessagePart[] = [];\n\t\tfor (const call of calls) {\n\t\t\t// A call before this one may have ignored the cancellation; this one must not start,\n\t\t\t// nor be announced as running.\n\t\t\tsignal.throwIfAborted();\n\t\t\t// Announced only as its turn comes, so a consumer never shows a call as running early.\n\t\t\tnarrate(call, callback);\n\t\t\tconst result = await this.runTool(call, signal);\n\t\t\tnarrate(result, callback);\n\t\t\tresults.push(result);\n\t\t}\n\n\t\treturn { role: 'tool', content: results };\n\t}\n\n\tasync run(prompt: string, signal: AbortSignal, callback: Callback): Promise<AgentResponse> {\n\t\tconst start = Date.now();\n\t\tconst runMessages: Message[] = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];\n\t\tlet tokens: Tokens | undefined;\n\t\t// Once one call went unreported the run's total is unknown, however many report later.\n\t\tlet unreported = false;\n\t\tlet toolRan = false;\n\n\t\ttry {\n\t\t\tfor (let step = 1; step <= this.maxSteps; step++) {\n\t\t\t\tconst response = await this.llmCall(runMessages, signal);\n\t\t\t\ttokens = addTokens(tokens, response.usage);\n\t\t\t\tunreported ||= !response.usage;\n\t\t\t\trunMessages.push(response.message);\n\n\t\t\t\t// Tool calls are narrated as they start, in `runTools`, and never if they do not run.\n\t\t\t\tfor (const part of response.message.content) {\n\t\t\t\t\tif (part.type !== 'toolCall') narrate(part, callback);\n\t\t\t\t}\n\n\t\t\t\tconst calls = response.message.content.filter(part => part.type === 'toolCall');\n\t\t\t\tif (calls.length === 0) {\n\t\t\t\t\t// Recorded only now, so a failed run leaves no tool call without its result.\n\t\t\t\t\tthis.conversation.addRun(runMessages);\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\t// Only the final answer: text from earlier steps was narrated as it came.\n\t\t\t\t\t\tresponse: response.message.content\n\t\t\t\t\t\t\t.filter(part => part.type === 'text')\n\t\t\t\t\t\t\t.map(part => part.text)\n\t\t\t\t\t\t\t.join('\\n'),\n\t\t\t\t\t\ttokens: unreported ? undefined : tokens,\n\t\t\t\t\t\tduration: (Date.now() - start) / 1000,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\t// On the last step no call is left to send the results to, so the tools do not run.\n\t\t\t\tif (step < this.maxSteps) {\n\t\t\t\t\t// Only a call to a tool the agent has reaches `execute`; a missing one has no\n\t\t\t\t\t// effect a retry could repeat.\n\t\t\t\t\ttoolRan ||= calls.some(call => this.tools.has(call.name));\n\t\t\t\t\trunMessages.push(await this.runTools(calls, signal, callback));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthrow new UnrecoverableError('The LLM did not finish within its step limit', {\n\t\t\t\tcause: `The model was still calling tools after ${String(this.maxSteps)} steps.`,\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tlet failure = error;\n\t\t\tif (toolRan && error instanceof RecoverableError) {\n\t\t\t\t// RetryingAgent would run the whole prompt again, and with it every tool this run\n\t\t\t\t// already executed, so a failure after one ran must not invite a retry.\n\t\t\t\tconst unrecoverable = new UnrecoverableError(error.message, {\n\t\t\t\t\tcause: error.cause,\n\t\t\t\t\ttokens: error.tokens,\n\t\t\t\t\tusageUnreported: error.usageUnreported,\n\t\t\t\t});\n\t\t\t\tunrecoverable.stack = error.stack;\n\t\t\t\tfailure = unrecoverable;\n\t\t\t}\n\n\t\t\t// The run spent the tokens of every step before the one that failed.\n\t\t\tthrow withSpentTokens(failure, tokens, unreported);\n\t\t}\n\t}\n}\n"]}
|
|
@@ -2,16 +2,9 @@ export interface JSONSchema {
|
|
|
2
2
|
type: 'object';
|
|
3
3
|
[keyword: string]: unknown;
|
|
4
4
|
}
|
|
5
|
+
/** What a client describes to the model. Running the tool is not the model's concern. */
|
|
5
6
|
export interface ToolDefinition {
|
|
6
7
|
name: string;
|
|
7
8
|
description: string;
|
|
8
9
|
inputSchema: JSONSchema;
|
|
9
10
|
}
|
|
10
|
-
export interface Tool extends ToolDefinition {
|
|
11
|
-
/**
|
|
12
|
-
* Runs the tool on the input the model sent. The input is unknown because the model can
|
|
13
|
-
* send anything: validating it is the tool's job, and a rejection returns to the model as
|
|
14
|
-
* an error result.
|
|
15
|
-
*/
|
|
16
|
-
execute(input: unknown, signal: AbortSignal): Promise<string>;
|
|
17
|
-
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool.js","sourceRoot":"","sources":["../../../src/llm/domain/tool.ts"],"names":[],"mappings":"","sourcesContent":["export interface JSONSchema {\n\ttype: 'object';\n\t[keyword: string]: unknown;\n}\n\
|
|
1
|
+
{"version":3,"file":"tool.js","sourceRoot":"","sources":["../../../src/llm/domain/tool.ts"],"names":[],"mappings":"","sourcesContent":["export interface JSONSchema {\n\ttype: 'object';\n\t[keyword: string]: unknown;\n}\n\n/** What a client describes to the model. Running the tool is not the model's concern. */\nexport interface ToolDefinition {\n\tname: string;\n\tdescription: string;\n\tinputSchema: JSONSchema;\n}\n"]}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ToolDefinition } from '#src/llm/domain/tool';
|
|
2
|
+
export interface Tool extends ToolDefinition {
|
|
3
|
+
/**
|
|
4
|
+
* Runs the tool on the input the model sent. The input is unknown because the model can
|
|
5
|
+
* send anything: validating it is the tool's job, and a rejection returns to the model as
|
|
6
|
+
* an error result.
|
|
7
|
+
*/
|
|
8
|
+
execute(input: unknown, signal: AbortSignal): Promise<string>;
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool.js","sourceRoot":"","sources":["../../../src/tools/domain/tool.ts"],"names":[],"mappings":"","sourcesContent":["import type { ToolDefinition } from '#src/llm/domain/tool';\n\nexport interface Tool extends ToolDefinition {\n\t/**\n\t * Runs the tool on the input the model sent. The input is unknown because the model can\n\t * send anything: validating it is the tool's job, and a rejection returns to the model as\n\t * an error result.\n\t */\n\texecute(input: unknown, signal: AbortSignal): Promise<string>;\n}\n"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface TextMatch {
|
|
2
|
+
path: string;
|
|
3
|
+
line: number;
|
|
4
|
+
text: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The folder an agent works on, read-only. Every path is relative to its root, written with
|
|
8
|
+
* `/`, and results come sorted. A file `.gitignore` ignores, or a path outside the root, does
|
|
9
|
+
* not exist here: it is never listed, searched or read, whatever scope a query asks for.
|
|
10
|
+
*
|
|
11
|
+
* A binary file is listed, but never searched or read. Every operation stops after a time
|
|
12
|
+
* limit and fails with an error that asks for a narrower query.
|
|
13
|
+
*
|
|
14
|
+
* `total` counts everything the query matched and `truncated` says whether `limit` cut it.
|
|
15
|
+
*/
|
|
16
|
+
export interface Workspace {
|
|
17
|
+
listFiles(query: {
|
|
18
|
+
path?: string;
|
|
19
|
+
glob?: string;
|
|
20
|
+
limit: number;
|
|
21
|
+
}, signal: AbortSignal): Promise<{
|
|
22
|
+
files: string[];
|
|
23
|
+
total: number;
|
|
24
|
+
truncated: boolean;
|
|
25
|
+
}>;
|
|
26
|
+
/** `pattern` is a regular expression. A match's `text` is at most 1,000 characters. */
|
|
27
|
+
searchText(query: {
|
|
28
|
+
pattern: string;
|
|
29
|
+
ignoreCase: boolean;
|
|
30
|
+
path?: string;
|
|
31
|
+
glob?: string;
|
|
32
|
+
limit: number;
|
|
33
|
+
}, signal: AbortSignal): Promise<{
|
|
34
|
+
matches: TextMatch[];
|
|
35
|
+
total: number;
|
|
36
|
+
truncated: boolean;
|
|
37
|
+
}>;
|
|
38
|
+
/** `fromLine` starts at 1. */
|
|
39
|
+
readFile(query: {
|
|
40
|
+
path: string;
|
|
41
|
+
fromLine: number;
|
|
42
|
+
lineCount: number;
|
|
43
|
+
}, signal: AbortSignal): Promise<{
|
|
44
|
+
lines: string[];
|
|
45
|
+
totalLines: number;
|
|
46
|
+
truncated: boolean;
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace.js","sourceRoot":"","sources":["../../../src/tools/domain/workspace.ts"],"names":[],"mappings":"","sourcesContent":["export interface TextMatch {\n\tpath: string;\n\tline: number;\n\ttext: string;\n}\n\n/**\n * The folder an agent works on, read-only. Every path is relative to its root, written with\n * `/`, and results come sorted. A file `.gitignore` ignores, or a path outside the root, does\n * not exist here: it is never listed, searched or read, whatever scope a query asks for.\n *\n * A binary file is listed, but never searched or read. Every operation stops after a time\n * limit and fails with an error that asks for a narrower query.\n *\n * `total` counts everything the query matched and `truncated` says whether `limit` cut it.\n */\nexport interface Workspace {\n\tlistFiles(\n\t\tquery: { path?: string; glob?: string; limit: number },\n\t\tsignal: AbortSignal,\n\t): Promise<{ files: string[]; total: number; truncated: boolean }>;\n\n\t/** `pattern` is a regular expression. A match's `text` is at most 1,000 characters. */\n\tsearchText(\n\t\tquery: { pattern: string; ignoreCase: boolean; path?: string; glob?: string; limit: number },\n\t\tsignal: AbortSignal,\n\t): Promise<{ matches: TextMatch[]; total: number; truncated: boolean }>;\n\n\t/** `fromLine` starts at 1. */\n\treadFile(\n\t\tquery: { path: string; fromLine: number; lineCount: number },\n\t\tsignal: AbortSignal,\n\t): Promise<{ lines: string[]; totalLines: number; truncated: boolean }>;\n}\n"]}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { TextMatch, Workspace } from '../domain/workspace.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Everything both implementations share: the boundary and the shape of every answer. A
|
|
4
|
+
* subclass only says which files are visible and where a pattern matches, across the whole
|
|
5
|
+
* root; the scope a model asks for is applied here, to those results, and never handed to the
|
|
6
|
+
* program underneath. ripgrep stops honouring `.gitignore` for a path it is given explicitly,
|
|
7
|
+
* so passing the model's scope down is how a secret would leak. See "`.gitignore` is the
|
|
8
|
+
* boundary of what an agent reads" in decisions.md.
|
|
9
|
+
*
|
|
10
|
+
* Every operation also stops after `timeoutMs`. A timeout is a failure the model reads and
|
|
11
|
+
* narrows its query from, not a cancellation: only the run's own signal cancels the run.
|
|
12
|
+
*/
|
|
13
|
+
export declare abstract class BoundedWorkspace implements Workspace {
|
|
14
|
+
protected readonly root: string;
|
|
15
|
+
private readonly timeoutMs;
|
|
16
|
+
constructor({ root, timeoutMs }: {
|
|
17
|
+
root: string;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
});
|
|
20
|
+
/** Every file the program does not ignore, relative to the root, in any order. */
|
|
21
|
+
protected abstract listCandidates(signal: AbortSignal): Promise<string[]>;
|
|
22
|
+
/**
|
|
23
|
+
* Hands `keep` every line matching `pattern` in a file the program does not ignore, in any
|
|
24
|
+
* order, as the program prints it. Nothing is held here: `keep` decides what to store.
|
|
25
|
+
*/
|
|
26
|
+
protected abstract findMatches(pattern: string, ignoreCase: boolean, signal: AbortSignal, keep: (match: TextMatch) => void): Promise<void>;
|
|
27
|
+
listFiles(query: {
|
|
28
|
+
path?: string;
|
|
29
|
+
glob?: string;
|
|
30
|
+
limit: number;
|
|
31
|
+
}, signal: AbortSignal): Promise<{
|
|
32
|
+
files: string[];
|
|
33
|
+
total: number;
|
|
34
|
+
truncated: boolean;
|
|
35
|
+
}>;
|
|
36
|
+
searchText(query: {
|
|
37
|
+
pattern: string;
|
|
38
|
+
ignoreCase: boolean;
|
|
39
|
+
path?: string;
|
|
40
|
+
glob?: string;
|
|
41
|
+
limit: number;
|
|
42
|
+
}, signal: AbortSignal): Promise<{
|
|
43
|
+
matches: TextMatch[];
|
|
44
|
+
total: number;
|
|
45
|
+
truncated: boolean;
|
|
46
|
+
}>;
|
|
47
|
+
readFile(query: {
|
|
48
|
+
path: string;
|
|
49
|
+
fromLine: number;
|
|
50
|
+
lineCount: number;
|
|
51
|
+
}, signal: AbortSignal): Promise<{
|
|
52
|
+
lines: string[];
|
|
53
|
+
totalLines: number;
|
|
54
|
+
truncated: boolean;
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* Runs an operation under the run's signal and this workspace's time limit together. The
|
|
58
|
+
* run's cancellation escapes unchanged; the time limit becomes an error the model can act on.
|
|
59
|
+
*/
|
|
60
|
+
private withTimeout;
|
|
61
|
+
/** Regular files only: a symlink could point out of the root, and a deleted file is gone. */
|
|
62
|
+
private regularFiles;
|
|
63
|
+
private inScope;
|
|
64
|
+
/** A path relative to the root with `/`, '' for the root itself, or undefined when it leaves it. */
|
|
65
|
+
private toRelative;
|
|
66
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { lstat, open } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, join, matchesGlob, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
// A NUL byte this early means a binary file, as git and ripgrep both judge it.
|
|
6
|
+
const binaryProbeBytes = 8000;
|
|
7
|
+
// What a search holds while it sorts. A minified file can put a whole bundle on one line, and
|
|
8
|
+
// a careless pattern can match every line of a repository.
|
|
9
|
+
const maxStoredMatches = 50_000;
|
|
10
|
+
const maxStoredLineLength = 1_000;
|
|
11
|
+
// Far beyond what ripgrep needs on a large repository, so only a runaway search reaches it,
|
|
12
|
+
// such as a pattern that backtracks without end in git's Perl engine.
|
|
13
|
+
const defaultTimeoutMs = 30_000;
|
|
14
|
+
function compareText(a, b) {
|
|
15
|
+
// Plain code-unit order: `localeCompare` would sort differently on every machine.
|
|
16
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Everything both implementations share: the boundary and the shape of every answer. A
|
|
20
|
+
* subclass only says which files are visible and where a pattern matches, across the whole
|
|
21
|
+
* root; the scope a model asks for is applied here, to those results, and never handed to the
|
|
22
|
+
* program underneath. ripgrep stops honouring `.gitignore` for a path it is given explicitly,
|
|
23
|
+
* so passing the model's scope down is how a secret would leak. See "`.gitignore` is the
|
|
24
|
+
* boundary of what an agent reads" in decisions.md.
|
|
25
|
+
*
|
|
26
|
+
* Every operation also stops after `timeoutMs`. A timeout is a failure the model reads and
|
|
27
|
+
* narrows its query from, not a cancellation: only the run's own signal cancels the run.
|
|
28
|
+
*/
|
|
29
|
+
export class BoundedWorkspace {
|
|
30
|
+
root;
|
|
31
|
+
timeoutMs;
|
|
32
|
+
constructor({ root, timeoutMs = defaultTimeoutMs }) {
|
|
33
|
+
this.root = root;
|
|
34
|
+
this.timeoutMs = timeoutMs;
|
|
35
|
+
}
|
|
36
|
+
listFiles(query, signal) {
|
|
37
|
+
return this.withTimeout(signal, async (bounded) => {
|
|
38
|
+
const inScope = [...new Set(await this.listCandidates(bounded))].filter(path => this.inScope(path, query));
|
|
39
|
+
const files = (await this.regularFiles(inScope)).sort(compareText);
|
|
40
|
+
return {
|
|
41
|
+
files: files.slice(0, query.limit),
|
|
42
|
+
total: files.length,
|
|
43
|
+
truncated: files.length > query.limit,
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
searchText(query, signal) {
|
|
48
|
+
return this.withTimeout(signal, async (bounded) => {
|
|
49
|
+
const listed = new Set(await this.listCandidates(bounded));
|
|
50
|
+
// The scope applies as the output arrives, so narrowing a search is what makes it
|
|
51
|
+
// lighter: matches outside it are never stored, however many the program prints.
|
|
52
|
+
const kept = [];
|
|
53
|
+
await this.findMatches(query.pattern, query.ignoreCase, bounded, match => {
|
|
54
|
+
if (!listed.has(match.path) || !this.inScope(match.path, query)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (kept.length === maxStoredMatches) {
|
|
58
|
+
throw new Error(`More than ${String(maxStoredMatches)} lines match; narrow the pattern, path or glob`);
|
|
59
|
+
}
|
|
60
|
+
kept.push({ ...match, text: match.text.slice(0, maxStoredLineLength) });
|
|
61
|
+
});
|
|
62
|
+
// Only the files that matched are checked on disk, not the whole repository.
|
|
63
|
+
const regular = new Set(await this.regularFiles([...new Set(kept.map(match => match.path))]));
|
|
64
|
+
const matches = kept
|
|
65
|
+
.filter(match => regular.has(match.path))
|
|
66
|
+
.sort((a, b) => compareText(a.path, b.path) || a.line - b.line);
|
|
67
|
+
return {
|
|
68
|
+
matches: matches.slice(0, query.limit),
|
|
69
|
+
total: matches.length,
|
|
70
|
+
truncated: matches.length > query.limit,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
readFile(query, signal) {
|
|
75
|
+
return this.withTimeout(signal, async (bounded) => {
|
|
76
|
+
const path = this.toRelative(query.path);
|
|
77
|
+
const visible = path !== undefined &&
|
|
78
|
+
(await this.listCandidates(bounded)).includes(path) &&
|
|
79
|
+
(await this.regularFiles([path])).length === 1;
|
|
80
|
+
// One answer for a missing file and an ignored one, or the model would learn the secret exists.
|
|
81
|
+
if (!visible) {
|
|
82
|
+
throw new Error(`No such file: ${query.path}`);
|
|
83
|
+
}
|
|
84
|
+
const absolute = join(this.root, path);
|
|
85
|
+
if (await isBinary(absolute)) {
|
|
86
|
+
throw new Error(`Not a text file: ${query.path}`);
|
|
87
|
+
}
|
|
88
|
+
// Streamed, so a large file costs its length in lines, not in memory.
|
|
89
|
+
const reader = createInterface({
|
|
90
|
+
input: createReadStream(absolute, { encoding: 'utf8', signal: bounded }),
|
|
91
|
+
crlfDelay: Infinity,
|
|
92
|
+
});
|
|
93
|
+
const last = query.fromLine + query.lineCount - 1;
|
|
94
|
+
const lines = [];
|
|
95
|
+
let totalLines = 0;
|
|
96
|
+
for await (const line of reader) {
|
|
97
|
+
totalLines++;
|
|
98
|
+
if (totalLines >= query.fromLine && totalLines <= last) {
|
|
99
|
+
lines.push(line);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { lines, totalLines, truncated: totalLines > last };
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Runs an operation under the run's signal and this workspace's time limit together. The
|
|
107
|
+
* run's cancellation escapes unchanged; the time limit becomes an error the model can act on.
|
|
108
|
+
*/
|
|
109
|
+
async withTimeout(signal, operation) {
|
|
110
|
+
signal.throwIfAborted();
|
|
111
|
+
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
112
|
+
try {
|
|
113
|
+
return await operation(AbortSignal.any([signal, timeout]));
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
signal.throwIfAborted();
|
|
117
|
+
if (timeout.aborted) {
|
|
118
|
+
throw new Error(`The operation took longer than ${String(this.timeoutMs / 1000)} s; narrow the pattern, path or glob`, { cause: error });
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Regular files only: a symlink could point out of the root, and a deleted file is gone. */
|
|
124
|
+
async regularFiles(paths) {
|
|
125
|
+
const regular = await Promise.all(paths.map(path => lstat(join(this.root, path)).then(stats => stats.isFile(), () => false)));
|
|
126
|
+
return paths.filter((_, index) => regular[index]);
|
|
127
|
+
}
|
|
128
|
+
inScope(path, { path: scope, glob }) {
|
|
129
|
+
if (scope !== undefined) {
|
|
130
|
+
const folder = this.toRelative(scope);
|
|
131
|
+
if (folder === undefined) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (folder !== '' && path !== folder && !path.startsWith(`${folder}/`)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return glob === undefined || matchesGlob(path, glob);
|
|
139
|
+
}
|
|
140
|
+
/** A path relative to the root with `/`, '' for the root itself, or undefined when it leaves it. */
|
|
141
|
+
toRelative(path) {
|
|
142
|
+
const relativePath = relative(this.root, resolve(this.root, path));
|
|
143
|
+
if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
return relativePath.split(sep).join('/');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function isBinary(path) {
|
|
150
|
+
const file = await open(path);
|
|
151
|
+
try {
|
|
152
|
+
const probe = Buffer.alloc(binaryProbeBytes);
|
|
153
|
+
const { bytesRead } = await file.read(probe, 0, probe.length, 0);
|
|
154
|
+
return probe.subarray(0, bytesRead).includes(0);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
await file.close();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=boundedWorkspace.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"boundedWorkspace.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/boundedWorkspace.ts"],"names":[],"mappings":"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","sourcesContent":["import { createReadStream } from 'node:fs';\nimport { lstat, open } from 'node:fs/promises';\nimport { isAbsolute, join, matchesGlob, relative, resolve, sep } from 'node:path';\nimport { createInterface } from 'node:readline';\nimport type { TextMatch, Workspace } from '../domain/workspace.ts';\n\n// A NUL byte this early means a binary file, as git and ripgrep both judge it.\nconst binaryProbeBytes = 8000;\n\n// What a search holds while it sorts. A minified file can put a whole bundle on one line, and\n// a careless pattern can match every line of a repository.\nconst maxStoredMatches = 50_000;\nconst maxStoredLineLength = 1_000;\n\n// Far beyond what ripgrep needs on a large repository, so only a runaway search reaches it,\n// such as a pattern that backtracks without end in git's Perl engine.\nconst defaultTimeoutMs = 30_000;\n\nfunction compareText(a: string, b: string): number {\n\t// Plain code-unit order: `localeCompare` would sort differently on every machine.\n\treturn a < b ? -1 : a > b ? 1 : 0;\n}\n\n/**\n * Everything both implementations share: the boundary and the shape of every answer. A\n * subclass only says which files are visible and where a pattern matches, across the whole\n * root; the scope a model asks for is applied here, to those results, and never handed to the\n * program underneath. ripgrep stops honouring `.gitignore` for a path it is given explicitly,\n * so passing the model's scope down is how a secret would leak. See \"`.gitignore` is the\n * boundary of what an agent reads\" in decisions.md.\n *\n * Every operation also stops after `timeoutMs`. A timeout is a failure the model reads and\n * narrows its query from, not a cancellation: only the run's own signal cancels the run.\n */\nexport abstract class BoundedWorkspace implements Workspace {\n\tprotected readonly root: string;\n\tprivate readonly timeoutMs: number;\n\n\tconstructor({ root, timeoutMs = defaultTimeoutMs }: { root: string; timeoutMs?: number }) {\n\t\tthis.root = root;\n\t\tthis.timeoutMs = timeoutMs;\n\t}\n\n\t/** Every file the program does not ignore, relative to the root, in any order. */\n\tprotected abstract listCandidates(signal: AbortSignal): Promise<string[]>;\n\n\t/**\n\t * Hands `keep` every line matching `pattern` in a file the program does not ignore, in any\n\t * order, as the program prints it. Nothing is held here: `keep` decides what to store.\n\t */\n\tprotected abstract findMatches(\n\t\tpattern: string,\n\t\tignoreCase: boolean,\n\t\tsignal: AbortSignal,\n\t\tkeep: (match: TextMatch) => void,\n\t): Promise<void>;\n\n\tlistFiles(\n\t\tquery: { path?: string; glob?: string; limit: number },\n\t\tsignal: AbortSignal,\n\t): Promise<{ files: string[]; total: number; truncated: boolean }> {\n\t\treturn this.withTimeout(signal, async bounded => {\n\t\t\tconst inScope = [...new Set(await this.listCandidates(bounded))].filter(path =>\n\t\t\t\tthis.inScope(path, query),\n\t\t\t);\n\t\t\tconst files = (await this.regularFiles(inScope)).sort(compareText);\n\n\t\t\treturn {\n\t\t\t\tfiles: files.slice(0, query.limit),\n\t\t\t\ttotal: files.length,\n\t\t\t\ttruncated: files.length > query.limit,\n\t\t\t};\n\t\t});\n\t}\n\n\tsearchText(\n\t\tquery: { pattern: string; ignoreCase: boolean; path?: string; glob?: string; limit: number },\n\t\tsignal: AbortSignal,\n\t): Promise<{ matches: TextMatch[]; total: number; truncated: boolean }> {\n\t\treturn this.withTimeout(signal, async bounded => {\n\t\t\tconst listed = new Set(await this.listCandidates(bounded));\n\t\t\t// The scope applies as the output arrives, so narrowing a search is what makes it\n\t\t\t// lighter: matches outside it are never stored, however many the program prints.\n\t\t\tconst kept: TextMatch[] = [];\n\t\t\tawait this.findMatches(query.pattern, query.ignoreCase, bounded, match => {\n\t\t\t\tif (!listed.has(match.path) || !this.inScope(match.path, query)) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (kept.length === maxStoredMatches) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`More than ${String(maxStoredMatches)} lines match; narrow the pattern, path or glob`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tkept.push({ ...match, text: match.text.slice(0, maxStoredLineLength) });\n\t\t\t});\n\n\t\t\t// Only the files that matched are checked on disk, not the whole repository.\n\t\t\tconst regular = new Set(await this.regularFiles([...new Set(kept.map(match => match.path))]));\n\t\t\tconst matches = kept\n\t\t\t\t.filter(match => regular.has(match.path))\n\t\t\t\t.sort((a, b) => compareText(a.path, b.path) || a.line - b.line);\n\n\t\t\treturn {\n\t\t\t\tmatches: matches.slice(0, query.limit),\n\t\t\t\ttotal: matches.length,\n\t\t\t\ttruncated: matches.length > query.limit,\n\t\t\t};\n\t\t});\n\t}\n\n\treadFile(\n\t\tquery: { path: string; fromLine: number; lineCount: number },\n\t\tsignal: AbortSignal,\n\t): Promise<{ lines: string[]; totalLines: number; truncated: boolean }> {\n\t\treturn this.withTimeout(signal, async bounded => {\n\t\t\tconst path = this.toRelative(query.path);\n\t\t\tconst visible =\n\t\t\t\tpath !== undefined &&\n\t\t\t\t(await this.listCandidates(bounded)).includes(path) &&\n\t\t\t\t(await this.regularFiles([path])).length === 1;\n\t\t\t// One answer for a missing file and an ignored one, or the model would learn the secret exists.\n\t\t\tif (!visible) {\n\t\t\t\tthrow new Error(`No such file: ${query.path}`);\n\t\t\t}\n\n\t\t\tconst absolute = join(this.root, path);\n\t\t\tif (await isBinary(absolute)) {\n\t\t\t\tthrow new Error(`Not a text file: ${query.path}`);\n\t\t\t}\n\n\t\t\t// Streamed, so a large file costs its length in lines, not in memory.\n\t\t\tconst reader = createInterface({\n\t\t\t\tinput: createReadStream(absolute, { encoding: 'utf8', signal: bounded }),\n\t\t\t\tcrlfDelay: Infinity,\n\t\t\t});\n\t\t\tconst last = query.fromLine + query.lineCount - 1;\n\t\t\tconst lines: string[] = [];\n\t\t\tlet totalLines = 0;\n\t\t\tfor await (const line of reader) {\n\t\t\t\ttotalLines++;\n\t\t\t\tif (totalLines >= query.fromLine && totalLines <= last) {\n\t\t\t\t\tlines.push(line);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn { lines, totalLines, truncated: totalLines > last };\n\t\t});\n\t}\n\n\t/**\n\t * Runs an operation under the run's signal and this workspace's time limit together. The\n\t * run's cancellation escapes unchanged; the time limit becomes an error the model can act on.\n\t */\n\tprivate async withTimeout<Result>(\n\t\tsignal: AbortSignal,\n\t\toperation: (bounded: AbortSignal) => Promise<Result>,\n\t): Promise<Result> {\n\t\tsignal.throwIfAborted();\n\t\tconst timeout = AbortSignal.timeout(this.timeoutMs);\n\t\ttry {\n\t\t\treturn await operation(AbortSignal.any([signal, timeout]));\n\t\t} catch (error) {\n\t\t\tsignal.throwIfAborted();\n\t\t\tif (timeout.aborted) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`The operation took longer than ${String(this.timeoutMs / 1000)} s; narrow the pattern, path or glob`,\n\t\t\t\t\t{ cause: error },\n\t\t\t\t);\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t/** Regular files only: a symlink could point out of the root, and a deleted file is gone. */\n\tprivate async regularFiles(paths: string[]): Promise<string[]> {\n\t\tconst regular = await Promise.all(\n\t\t\tpaths.map(path =>\n\t\t\t\tlstat(join(this.root, path)).then(\n\t\t\t\t\tstats => stats.isFile(),\n\t\t\t\t\t() => false,\n\t\t\t\t),\n\t\t\t),\n\t\t);\n\n\t\treturn paths.filter((_, index) => regular[index]);\n\t}\n\n\tprivate inScope(path: string, { path: scope, glob }: { path?: string; glob?: string }): boolean {\n\t\tif (scope !== undefined) {\n\t\t\tconst folder = this.toRelative(scope);\n\t\t\tif (folder === undefined) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif (folder !== '' && path !== folder && !path.startsWith(`${folder}/`)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn glob === undefined || matchesGlob(path, glob);\n\t}\n\n\t/** A path relative to the root with `/`, '' for the root itself, or undefined when it leaves it. */\n\tprivate toRelative(path: string): string | undefined {\n\t\tconst relativePath = relative(this.root, resolve(this.root, path));\n\t\tif (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\treturn relativePath.split(sep).join('/');\n\t}\n}\n\nasync function isBinary(path: string): Promise<boolean> {\n\tconst file = await open(path);\n\ttry {\n\t\tconst probe = Buffer.alloc(binaryProbeBytes);\n\t\tconst { bytesRead } = await file.read(probe, 0, probe.length, 0);\n\t\treturn probe.subarray(0, bytesRead).includes(0);\n\t} finally {\n\t\tawait file.close();\n\t}\n}\n"]}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ILogger } from '#src/shared/domain/logger';
|
|
2
|
+
import type { Workspace } from '../domain/workspace.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Picks how to read `root`: the ripgrep that ships with the package, or git when this platform
|
|
5
|
+
* has no ripgrep binary, which is logged because it is a degraded path. Plain `grep` is never a
|
|
6
|
+
* fallback: it knows nothing of `.gitignore`. See "`.gitignore` is the boundary of what an agent
|
|
7
|
+
* reads" in decisions.md.
|
|
8
|
+
*
|
|
9
|
+
* @throws {UnrecoverableError} when neither ripgrep nor a git repository is available.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createWorkspace({ root, logger, }: {
|
|
12
|
+
root: string;
|
|
13
|
+
logger: ILogger;
|
|
14
|
+
}): Promise<Workspace>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { UnrecoverableError } from '#src/agent/domain/errors';
|
|
2
|
+
import { classifyHostFailure, treatErrors } from '#src/agent/domain/providerFailure';
|
|
3
|
+
import { GitWorkspace } from "./gitWorkspace.js";
|
|
4
|
+
import { RipgrepWorkspace } from "./ripgrepWorkspace.js";
|
|
5
|
+
import { runProcess } from "./runProcess.js";
|
|
6
|
+
// Long enough for a slow disk; short enough that a hung git does not hold up building an agent.
|
|
7
|
+
const gitCheckTimeoutMs = 5_000;
|
|
8
|
+
async function loadRipgrep() {
|
|
9
|
+
try {
|
|
10
|
+
// Dynamic: the package throws on import when the platform has no binary.
|
|
11
|
+
const { rgPath } = await import('@vscode/ripgrep');
|
|
12
|
+
return rgPath;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function isGitWorkTree(root) {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout, exitCode } = await runProcess('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
21
|
+
cwd: root,
|
|
22
|
+
signal: AbortSignal.timeout(gitCheckTimeoutMs),
|
|
23
|
+
});
|
|
24
|
+
return exitCode === 0 && stdout.trim() === 'true';
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// No git at all, or one that never answered.
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Picks how to read `root`: the ripgrep that ships with the package, or git when this platform
|
|
33
|
+
* has no ripgrep binary, which is logged because it is a degraded path. Plain `grep` is never a
|
|
34
|
+
* fallback: it knows nothing of `.gitignore`. See "`.gitignore` is the boundary of what an agent
|
|
35
|
+
* reads" in decisions.md.
|
|
36
|
+
*
|
|
37
|
+
* @throws {UnrecoverableError} when neither ripgrep nor a git repository is available.
|
|
38
|
+
*/
|
|
39
|
+
export async function createWorkspace({ root, logger, }) {
|
|
40
|
+
const ripgrepPath = await loadRipgrep();
|
|
41
|
+
if (ripgrepPath !== undefined) {
|
|
42
|
+
return new RipgrepWorkspace({ root, ripgrepPath });
|
|
43
|
+
}
|
|
44
|
+
if (await isGitWorkTree(root)) {
|
|
45
|
+
treatErrors(() => {
|
|
46
|
+
logger.warn('ripgrep is not available on this platform; reading the repository through git');
|
|
47
|
+
}, classifyHostFailure, 'Workspace logger failed while choosing an implementation');
|
|
48
|
+
return new GitWorkspace({ root });
|
|
49
|
+
}
|
|
50
|
+
throw new UnrecoverableError('No program can read the workspace', {
|
|
51
|
+
cause: `ripgrep is not available on this platform, and ${root} is not a git repository.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=createWorkspace.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"createWorkspace.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/createWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAE,MAAM,mCAAmC,CAAC;AAGrF,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,gGAAgG;AAChG,MAAM,iBAAiB,GAAG,KAAK,CAAC;AAEhC,KAAK,UAAU,WAAW;IACzB,IAAI,CAAC;QACJ,yEAAyE;QACzE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,iBAAiB,CAAC,CAAC;QACnD,OAAO,MAAM,CAAC;IACf,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,SAAS,CAAC;IAClB,CAAC;AACF,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,IAAY;IACxC,IAAI,CAAC;QACJ,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,UAAU,CAAC,KAAK,EAAE,CAAC,WAAW,EAAE,uBAAuB,CAAC,EAAE;YAC5F,GAAG,EAAE,IAAI;YACT,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,iBAAiB,CAAC;SAC9C,CAAC,CAAC;QACH,OAAO,QAAQ,KAAK,CAAC,IAAI,MAAM,CAAC,IAAI,EAAE,KAAK,MAAM,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACR,6CAA6C;QAC7C,OAAO,KAAK,CAAC;IACd,CAAC;AACF,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,EACrC,IAAI,EACJ,MAAM,GAIN;IACA,MAAM,WAAW,GAAG,MAAM,WAAW,EAAE,CAAC;IACxC,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;QAC/B,OAAO,IAAI,gBAAgB,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAC;IACpD,CAAC;IAED,IAAI,MAAM,aAAa,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/B,WAAW,CACV,GAAG,EAAE;YACJ,MAAM,CAAC,IAAI,CACV,+EAA+E,CAC/E,CAAC;QACH,CAAC,EACD,mBAAmB,EACnB,0DAA0D,CAC1D,CAAC;QACF,OAAO,IAAI,YAAY,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;IACnC,CAAC;IAED,MAAM,IAAI,kBAAkB,CAAC,mCAAmC,EAAE;QACjE,KAAK,EAAE,kDAAkD,IAAI,2BAA2B;KACxF,CAAC,CAAC;AACJ,CAAC","sourcesContent":["import { UnrecoverableError } from '#src/agent/domain/errors';\nimport { classifyHostFailure, treatErrors } from '#src/agent/domain/providerFailure';\nimport type { ILogger } from '#src/shared/domain/logger';\nimport type { Workspace } from '../domain/workspace.ts';\nimport { GitWorkspace } from './gitWorkspace.ts';\nimport { RipgrepWorkspace } from './ripgrepWorkspace.ts';\nimport { runProcess } from './runProcess.ts';\n\n// Long enough for a slow disk; short enough that a hung git does not hold up building an agent.\nconst gitCheckTimeoutMs = 5_000;\n\nasync function loadRipgrep(): Promise<string | undefined> {\n\ttry {\n\t\t// Dynamic: the package throws on import when the platform has no binary.\n\t\tconst { rgPath } = await import('@vscode/ripgrep');\n\t\treturn rgPath;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nasync function isGitWorkTree(root: string): Promise<boolean> {\n\ttry {\n\t\tconst { stdout, exitCode } = await runProcess('git', ['rev-parse', '--is-inside-work-tree'], {\n\t\t\tcwd: root,\n\t\t\tsignal: AbortSignal.timeout(gitCheckTimeoutMs),\n\t\t});\n\t\treturn exitCode === 0 && stdout.trim() === 'true';\n\t} catch {\n\t\t// No git at all, or one that never answered.\n\t\treturn false;\n\t}\n}\n\n/**\n * Picks how to read `root`: the ripgrep that ships with the package, or git when this platform\n * has no ripgrep binary, which is logged because it is a degraded path. Plain `grep` is never a\n * fallback: it knows nothing of `.gitignore`. See \"`.gitignore` is the boundary of what an agent\n * reads\" in decisions.md.\n *\n * @throws {UnrecoverableError} when neither ripgrep nor a git repository is available.\n */\nexport async function createWorkspace({\n\troot,\n\tlogger,\n}: {\n\troot: string;\n\tlogger: ILogger;\n}): Promise<Workspace> {\n\tconst ripgrepPath = await loadRipgrep();\n\tif (ripgrepPath !== undefined) {\n\t\treturn new RipgrepWorkspace({ root, ripgrepPath });\n\t}\n\n\tif (await isGitWorkTree(root)) {\n\t\ttreatErrors(\n\t\t\t() => {\n\t\t\t\tlogger.warn(\n\t\t\t\t\t'ripgrep is not available on this platform; reading the repository through git',\n\t\t\t\t);\n\t\t\t},\n\t\t\tclassifyHostFailure,\n\t\t\t'Workspace logger failed while choosing an implementation',\n\t\t);\n\t\treturn new GitWorkspace({ root });\n\t}\n\n\tthrow new UnrecoverableError('No program can read the workspace', {\n\t\tcause: `ripgrep is not available on this platform, and ${root} is not a git repository.`,\n\t});\n}\n"]}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Tool } from '../domain/tool.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Builds a `Tool` from one Zod schema, which gives the model its JSON Schema, validates what the
|
|
5
|
+
* model sends, and types the input `execute` receives. Invalid input is rejected with a message
|
|
6
|
+
* naming what is wrong, which the agent hands back to the model as an error result.
|
|
7
|
+
*
|
|
8
|
+
* @throws {InvalidAgentConfigError} when the schema falls outside what strict mode supports.
|
|
9
|
+
*/
|
|
10
|
+
export declare function defineTool<Schema extends z.ZodObject>({ name, description, input, execute, }: {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
input: Schema;
|
|
14
|
+
execute: (input: z.infer<Schema>, signal: AbortSignal) => Promise<string>;
|
|
15
|
+
}): Tool;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { InvalidAgentConfigError } from '#src/agent/domain/errors';
|
|
3
|
+
// Both providers take tools in strict mode, which supports only part of JSON Schema. Claude
|
|
4
|
+
// rejects these bounds; a tool that needs one checks it in its own code instead.
|
|
5
|
+
const unsupportedKeywords = [
|
|
6
|
+
'minimum',
|
|
7
|
+
'maximum',
|
|
8
|
+
'exclusiveMinimum',
|
|
9
|
+
'exclusiveMaximum',
|
|
10
|
+
'multipleOf',
|
|
11
|
+
'minLength',
|
|
12
|
+
'maxLength',
|
|
13
|
+
];
|
|
14
|
+
// Keywords whose value is one schema, or a list or map of them. Anything else, such as the
|
|
15
|
+
// keys of `properties`, is a name or a value, never a schema to check.
|
|
16
|
+
const schemaKeywords = ['items', 'not', 'additionalProperties'];
|
|
17
|
+
const schemaListKeywords = ['anyOf', 'oneOf', 'allOf', 'prefixItems'];
|
|
18
|
+
const schemaMapKeywords = ['properties', '$defs', 'definitions'];
|
|
19
|
+
function isSchema(value) {
|
|
20
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Walks the whole schema, nested objects included, and visits only schemas: a property named
|
|
24
|
+
* `maxLength` is a name, not the keyword. A schema the providers reject would fail every
|
|
25
|
+
* request with a 400, so it fails here, when the tool is defined.
|
|
26
|
+
*/
|
|
27
|
+
function assertStrict(schema, toolName) {
|
|
28
|
+
if (!isSchema(schema)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const keyword = unsupportedKeywords.find(candidate => candidate in schema);
|
|
32
|
+
if (keyword) {
|
|
33
|
+
throw new InvalidAgentConfigError(`Tool "${toolName}" uses "${keyword}", which strict mode does not support; check it in the tool's code`);
|
|
34
|
+
}
|
|
35
|
+
if (schema.type === 'object') {
|
|
36
|
+
// A record or a loose object takes keys strict mode cannot know in advance.
|
|
37
|
+
if (schema.additionalProperties !== false) {
|
|
38
|
+
throw new InvalidAgentConfigError(`Tool "${toolName}" has an object open to any key; strict mode needs every key declared`);
|
|
39
|
+
}
|
|
40
|
+
const required = (schema.required ?? []);
|
|
41
|
+
// OpenAI requires every property: an optional one is a union with `null`.
|
|
42
|
+
const optional = Object.keys(schema.properties ?? {}).filter(property => !required.includes(property));
|
|
43
|
+
if (optional.length > 0) {
|
|
44
|
+
throw new InvalidAgentConfigError(`Tool "${toolName}" has optional fields (${optional.join(', ')}); make them .nullable() instead`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const key of schemaKeywords)
|
|
48
|
+
assertStrict(schema[key], toolName);
|
|
49
|
+
for (const key of schemaListKeywords) {
|
|
50
|
+
const list = schema[key];
|
|
51
|
+
if (Array.isArray(list))
|
|
52
|
+
for (const item of list)
|
|
53
|
+
assertStrict(item, toolName);
|
|
54
|
+
}
|
|
55
|
+
for (const key of schemaMapKeywords) {
|
|
56
|
+
const map = schema[key];
|
|
57
|
+
if (isSchema(map))
|
|
58
|
+
for (const item of Object.values(map))
|
|
59
|
+
assertStrict(item, toolName);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Builds a `Tool` from one Zod schema, which gives the model its JSON Schema, validates what the
|
|
64
|
+
* model sends, and types the input `execute` receives. Invalid input is rejected with a message
|
|
65
|
+
* naming what is wrong, which the agent hands back to the model as an error result.
|
|
66
|
+
*
|
|
67
|
+
* @throws {InvalidAgentConfigError} when the schema falls outside what strict mode supports.
|
|
68
|
+
*/
|
|
69
|
+
export function defineTool({ name, description, input, execute, }) {
|
|
70
|
+
const inputSchema = { ...z.toJSONSchema(input) };
|
|
71
|
+
// The dialect marker is noise to both APIs.
|
|
72
|
+
delete inputSchema.$schema;
|
|
73
|
+
assertStrict(inputSchema, name);
|
|
74
|
+
return {
|
|
75
|
+
name,
|
|
76
|
+
description,
|
|
77
|
+
inputSchema: inputSchema,
|
|
78
|
+
execute: (raw, signal) => {
|
|
79
|
+
const parsed = input.safeParse(raw);
|
|
80
|
+
if (!parsed.success) {
|
|
81
|
+
return Promise.reject(new Error(z.prettifyError(parsed.error)));
|
|
82
|
+
}
|
|
83
|
+
return execute(parsed.data, signal);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=defineTool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"defineTool.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/defineTool.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from 'zod';\nimport { InvalidAgentConfigError } from '#src/agent/domain/errors';\nimport type { JSONSchema } from '#src/llm/domain/tool';\nimport type { Tool } from '../domain/tool.ts';\n\n// Both providers take tools in strict mode, which supports only part of JSON Schema. Claude\n// rejects these bounds; a tool that needs one checks it in its own code instead.\nconst unsupportedKeywords = [\n\t'minimum',\n\t'maximum',\n\t'exclusiveMinimum',\n\t'exclusiveMaximum',\n\t'multipleOf',\n\t'minLength',\n\t'maxLength',\n];\n\n// Keywords whose value is one schema, or a list or map of them. Anything else, such as the\n// keys of `properties`, is a name or a value, never a schema to check.\nconst schemaKeywords = ['items', 'not', 'additionalProperties'];\nconst schemaListKeywords = ['anyOf', 'oneOf', 'allOf', 'prefixItems'];\nconst schemaMapKeywords = ['properties', '$defs', 'definitions'];\n\nfunction isSchema(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/**\n * Walks the whole schema, nested objects included, and visits only schemas: a property named\n * `maxLength` is a name, not the keyword. A schema the providers reject would fail every\n * request with a 400, so it fails here, when the tool is defined.\n */\nfunction assertStrict(schema: unknown, toolName: string): void {\n\tif (!isSchema(schema)) {\n\t\treturn;\n\t}\n\n\tconst keyword = unsupportedKeywords.find(candidate => candidate in schema);\n\tif (keyword) {\n\t\tthrow new InvalidAgentConfigError(\n\t\t\t`Tool \"${toolName}\" uses \"${keyword}\", which strict mode does not support; check it in the tool's code`,\n\t\t);\n\t}\n\n\tif (schema.type === 'object') {\n\t\t// A record or a loose object takes keys strict mode cannot know in advance.\n\t\tif (schema.additionalProperties !== false) {\n\t\t\tthrow new InvalidAgentConfigError(\n\t\t\t\t`Tool \"${toolName}\" has an object open to any key; strict mode needs every key declared`,\n\t\t\t);\n\t\t}\n\n\t\tconst required = (schema.required ?? []) as string[];\n\t\t// OpenAI requires every property: an optional one is a union with `null`.\n\t\tconst optional = Object.keys(schema.properties ?? {}).filter(\n\t\t\tproperty => !required.includes(property),\n\t\t);\n\t\tif (optional.length > 0) {\n\t\t\tthrow new InvalidAgentConfigError(\n\t\t\t\t`Tool \"${toolName}\" has optional fields (${optional.join(', ')}); make them .nullable() instead`,\n\t\t\t);\n\t\t}\n\t}\n\n\tfor (const key of schemaKeywords) assertStrict(schema[key], toolName);\n\tfor (const key of schemaListKeywords) {\n\t\tconst list = schema[key];\n\t\tif (Array.isArray(list)) for (const item of list) assertStrict(item, toolName);\n\t}\n\tfor (const key of schemaMapKeywords) {\n\t\tconst map = schema[key];\n\t\tif (isSchema(map)) for (const item of Object.values(map)) assertStrict(item, toolName);\n\t}\n}\n\n/**\n * Builds a `Tool` from one Zod schema, which gives the model its JSON Schema, validates what the\n * model sends, and types the input `execute` receives. Invalid input is rejected with a message\n * naming what is wrong, which the agent hands back to the model as an error result.\n *\n * @throws {InvalidAgentConfigError} when the schema falls outside what strict mode supports.\n */\nexport function defineTool<Schema extends z.ZodObject>({\n\tname,\n\tdescription,\n\tinput,\n\texecute,\n}: {\n\tname: string;\n\tdescription: string;\n\tinput: Schema;\n\texecute: (input: z.infer<Schema>, signal: AbortSignal) => Promise<string>;\n}): Tool {\n\tconst inputSchema: Record<string, unknown> = { ...z.toJSONSchema(input) };\n\t// The dialect marker is noise to both APIs.\n\tdelete inputSchema.$schema;\n\tassertStrict(inputSchema, name);\n\n\treturn {\n\t\tname,\n\t\tdescription,\n\t\tinputSchema: inputSchema as JSONSchema,\n\t\texecute: (raw, signal) => {\n\t\t\tconst parsed = input.safeParse(raw);\n\t\t\tif (!parsed.success) {\n\t\t\t\treturn Promise.reject(new Error(z.prettifyError(parsed.error)));\n\t\t\t}\n\n\t\t\treturn execute(parsed.data, signal);\n\t\t},\n\t};\n}\n"]}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TextMatch } from '../domain/workspace.ts';
|
|
2
|
+
import { BoundedWorkspace } from './boundedWorkspace.ts';
|
|
3
|
+
/**
|
|
4
|
+
* The fallback `Workspace`, on the git the repository already has. git applies `.gitignore`
|
|
5
|
+
* by itself, even to an explicit path, but only inside a repository: outside one, every
|
|
6
|
+
* operation fails.
|
|
7
|
+
*/
|
|
8
|
+
export declare class GitWorkspace extends BoundedWorkspace {
|
|
9
|
+
protected listCandidates(signal: AbortSignal): Promise<string[]>;
|
|
10
|
+
protected findMatches(pattern: string, ignoreCase: boolean, signal: AbortSignal, keep: (match: TextMatch) => void): Promise<void>;
|
|
11
|
+
private listFiles0;
|
|
12
|
+
private git;
|
|
13
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { BoundedWorkspace } from "./boundedWorkspace.js";
|
|
2
|
+
import { firstLine, runProcess } from "./runProcess.js";
|
|
3
|
+
/**
|
|
4
|
+
* The fallback `Workspace`, on the git the repository already has. git applies `.gitignore`
|
|
5
|
+
* by itself, even to an explicit path, but only inside a repository: outside one, every
|
|
6
|
+
* operation fails.
|
|
7
|
+
*/
|
|
8
|
+
export class GitWorkspace extends BoundedWorkspace {
|
|
9
|
+
async listCandidates(signal) {
|
|
10
|
+
// Tracked files and new ones not yet added; then the tracked ones `.gitignore` matches
|
|
11
|
+
// anyway, such as a `.env` committed before it was ignored. ripgrep hides those, and the
|
|
12
|
+
// fallback must not show what the preferred implementation hides.
|
|
13
|
+
const [listed, ignored] = await Promise.all([
|
|
14
|
+
this.listFiles0(['--cached', '--others', '--exclude-standard'], signal),
|
|
15
|
+
this.listFiles0(['--cached', '--ignored', '--exclude-standard'], signal),
|
|
16
|
+
]);
|
|
17
|
+
const hidden = new Set(ignored);
|
|
18
|
+
return listed.filter(path => !hidden.has(path));
|
|
19
|
+
}
|
|
20
|
+
async findMatches(pattern, ignoreCase, signal, keep) {
|
|
21
|
+
const { stderr, exitCode } = await this.git([
|
|
22
|
+
'grep',
|
|
23
|
+
// Untracked files too, or the agent could not search a file it has just created.
|
|
24
|
+
'--untracked',
|
|
25
|
+
// Binary files left out, as ripgrep does.
|
|
26
|
+
'-I',
|
|
27
|
+
'--line-number',
|
|
28
|
+
// path NUL line NUL text, so a colon in a path cannot split it.
|
|
29
|
+
'-z',
|
|
30
|
+
// Perl syntax, so `\d` and `\b` mean what they mean to ripgrep.
|
|
31
|
+
'-P',
|
|
32
|
+
...(ignoreCase ? ['--ignore-case'] : []),
|
|
33
|
+
// `-e`, so a pattern starting with a dash is never read as an option.
|
|
34
|
+
'-e',
|
|
35
|
+
pattern,
|
|
36
|
+
], signal, line => {
|
|
37
|
+
const [path, number, ...text] = line.split('\0');
|
|
38
|
+
if (path && number) {
|
|
39
|
+
keep({ path, line: Number(number), text: text.join('\0').replace(/\r$/, '') });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
// 1 means no match; anything above is a failure, such as a pattern that does not compile.
|
|
43
|
+
if (exitCode > 1) {
|
|
44
|
+
throw new Error(`git could not search for the pattern: ${firstLine(stderr)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async listFiles0(options, signal) {
|
|
48
|
+
const { stdout, stderr, exitCode } = await this.git(['ls-files', '-z', ...options], signal);
|
|
49
|
+
if (exitCode !== 0) {
|
|
50
|
+
throw new Error(`git could not list the files: ${firstLine(stderr)}`);
|
|
51
|
+
}
|
|
52
|
+
return stdout.split('\0').filter(path => path !== '');
|
|
53
|
+
}
|
|
54
|
+
git(args, signal, onLine) {
|
|
55
|
+
// A user's settings must not change the output this parses: colour codes would end up in
|
|
56
|
+
// the text, `grep.column` adds a field, and `grep.fullName` makes paths relative to the
|
|
57
|
+
// repository instead of the root.
|
|
58
|
+
return runProcess('git', ['-c', 'color.ui=never', '-c', 'grep.column=false', '-c', 'grep.fullName=false', ...args], { cwd: this.root, signal, onLine });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=gitWorkspace.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gitWorkspace.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/gitWorkspace.ts"],"names":[],"mappings":"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","sourcesContent":["import type { TextMatch } from '../domain/workspace.ts';\nimport { BoundedWorkspace } from './boundedWorkspace.ts';\nimport { firstLine, runProcess } from './runProcess.ts';\n\n/**\n * The fallback `Workspace`, on the git the repository already has. git applies `.gitignore`\n * by itself, even to an explicit path, but only inside a repository: outside one, every\n * operation fails.\n */\nexport class GitWorkspace extends BoundedWorkspace {\n\tprotected async listCandidates(signal: AbortSignal): Promise<string[]> {\n\t\t// Tracked files and new ones not yet added; then the tracked ones `.gitignore` matches\n\t\t// anyway, such as a `.env` committed before it was ignored. ripgrep hides those, and the\n\t\t// fallback must not show what the preferred implementation hides.\n\t\tconst [listed, ignored] = await Promise.all([\n\t\t\tthis.listFiles0(['--cached', '--others', '--exclude-standard'], signal),\n\t\t\tthis.listFiles0(['--cached', '--ignored', '--exclude-standard'], signal),\n\t\t]);\n\t\tconst hidden = new Set(ignored);\n\n\t\treturn listed.filter(path => !hidden.has(path));\n\t}\n\n\tprotected async findMatches(\n\t\tpattern: string,\n\t\tignoreCase: boolean,\n\t\tsignal: AbortSignal,\n\t\tkeep: (match: TextMatch) => void,\n\t): Promise<void> {\n\t\tconst { stderr, exitCode } = await this.git(\n\t\t\t[\n\t\t\t\t'grep',\n\t\t\t\t// Untracked files too, or the agent could not search a file it has just created.\n\t\t\t\t'--untracked',\n\t\t\t\t// Binary files left out, as ripgrep does.\n\t\t\t\t'-I',\n\t\t\t\t'--line-number',\n\t\t\t\t// path NUL line NUL text, so a colon in a path cannot split it.\n\t\t\t\t'-z',\n\t\t\t\t// Perl syntax, so `\\d` and `\\b` mean what they mean to ripgrep.\n\t\t\t\t'-P',\n\t\t\t\t...(ignoreCase ? ['--ignore-case'] : []),\n\t\t\t\t// `-e`, so a pattern starting with a dash is never read as an option.\n\t\t\t\t'-e',\n\t\t\t\tpattern,\n\t\t\t],\n\t\t\tsignal,\n\t\t\tline => {\n\t\t\t\tconst [path, number, ...text] = line.split('\\0');\n\t\t\t\tif (path && number) {\n\t\t\t\t\tkeep({ path, line: Number(number), text: text.join('\\0').replace(/\\r$/, '') });\n\t\t\t\t}\n\t\t\t},\n\t\t);\n\t\t// 1 means no match; anything above is a failure, such as a pattern that does not compile.\n\t\tif (exitCode > 1) {\n\t\t\tthrow new Error(`git could not search for the pattern: ${firstLine(stderr)}`);\n\t\t}\n\t}\n\n\tprivate async listFiles0(options: string[], signal: AbortSignal): Promise<string[]> {\n\t\tconst { stdout, stderr, exitCode } = await this.git(['ls-files', '-z', ...options], signal);\n\t\tif (exitCode !== 0) {\n\t\t\tthrow new Error(`git could not list the files: ${firstLine(stderr)}`);\n\t\t}\n\n\t\treturn stdout.split('\\0').filter(path => path !== '');\n\t}\n\n\tprivate git(args: string[], signal: AbortSignal, onLine?: (line: string) => void) {\n\t\t// A user's settings must not change the output this parses: colour codes would end up in\n\t\t// the text, `grep.column` adds a field, and `grep.fullName` makes paths relative to the\n\t\t// repository instead of the root.\n\t\treturn runProcess(\n\t\t\t'git',\n\t\t\t['-c', 'color.ui=never', '-c', 'grep.column=false', '-c', 'grep.fullName=false', ...args],\n\t\t\t{ cwd: this.root, signal, onLine },\n\t\t);\n\t}\n}\n"]}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TextMatch } from '../domain/workspace.ts';
|
|
2
|
+
import { BoundedWorkspace } from './boundedWorkspace.ts';
|
|
3
|
+
/**
|
|
4
|
+
* The preferred `Workspace`, on the ripgrep binary that ships with the package. It always runs
|
|
5
|
+
* from the root with no path: ripgrep ignores nothing that it is given explicitly.
|
|
6
|
+
*
|
|
7
|
+
* It takes the binary's path instead of importing `@vscode/ripgrep`, which throws on import when
|
|
8
|
+
* the platform has no binary; `createWorkspace` loads it where that failure can be handled.
|
|
9
|
+
*/
|
|
10
|
+
export declare class RipgrepWorkspace extends BoundedWorkspace {
|
|
11
|
+
private readonly ripgrepPath;
|
|
12
|
+
constructor({ ripgrepPath, ...options }: {
|
|
13
|
+
root: string;
|
|
14
|
+
ripgrepPath: string;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
});
|
|
17
|
+
protected listCandidates(signal: AbortSignal): Promise<string[]>;
|
|
18
|
+
protected findMatches(pattern: string, ignoreCase: boolean, signal: AbortSignal, keep: (match: TextMatch) => void): Promise<void>;
|
|
19
|
+
private ripgrep;
|
|
20
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { BoundedWorkspace } from "./boundedWorkspace.js";
|
|
2
|
+
import { firstLine, runProcess } from "./runProcess.js";
|
|
3
|
+
// Every call sees what git would. Each flag is ours; nothing a model writes ever reaches the
|
|
4
|
+
// command line except the pattern, and that only after `-e`.
|
|
5
|
+
const sameViewAsGit = [
|
|
6
|
+
// A user's ripgrep configuration could switch the ignore rules off.
|
|
7
|
+
'--no-config',
|
|
8
|
+
// ripgrep's own `.ignore` and `.rgignore` outrank `.gitignore`, so a `!.env` line in either
|
|
9
|
+
// would show a secret git hides. Only git's ignore sources count.
|
|
10
|
+
'--no-ignore-dot',
|
|
11
|
+
// Hidden files such as `.github/` are part of the repository; ripgrep skips them by default.
|
|
12
|
+
'--hidden',
|
|
13
|
+
// `.gitignore` applies even outside a git repository.
|
|
14
|
+
'--no-require-git',
|
|
15
|
+
// `--hidden` would otherwise search git's own folder.
|
|
16
|
+
'--glob',
|
|
17
|
+
'!.git',
|
|
18
|
+
// Windows would print `\`; every path in a `Workspace` is written with `/`.
|
|
19
|
+
'--path-separator',
|
|
20
|
+
'/',
|
|
21
|
+
];
|
|
22
|
+
function decode({ text, bytes }) {
|
|
23
|
+
return text ?? Buffer.from(bytes ?? '', 'base64').toString('utf8');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The preferred `Workspace`, on the ripgrep binary that ships with the package. It always runs
|
|
27
|
+
* from the root with no path: ripgrep ignores nothing that it is given explicitly.
|
|
28
|
+
*
|
|
29
|
+
* It takes the binary's path instead of importing `@vscode/ripgrep`, which throws on import when
|
|
30
|
+
* the platform has no binary; `createWorkspace` loads it where that failure can be handled.
|
|
31
|
+
*/
|
|
32
|
+
export class RipgrepWorkspace extends BoundedWorkspace {
|
|
33
|
+
ripgrepPath;
|
|
34
|
+
constructor({ ripgrepPath, ...options }) {
|
|
35
|
+
super(options);
|
|
36
|
+
this.ripgrepPath = ripgrepPath;
|
|
37
|
+
}
|
|
38
|
+
async listCandidates(signal) {
|
|
39
|
+
const { stdout, stderr, exitCode } = await this.ripgrep(['--files', '--null'], signal);
|
|
40
|
+
// 1 means nothing to list, which an empty repository is. 2 with a listing means some
|
|
41
|
+
// folder could not be read, which git skips with a warning too; without one, it failed.
|
|
42
|
+
if (exitCode > 1 && stdout === '') {
|
|
43
|
+
throw new Error(`ripgrep could not list the files: ${firstLine(stderr)}`);
|
|
44
|
+
}
|
|
45
|
+
return stdout
|
|
46
|
+
.split('\0')
|
|
47
|
+
.filter(path => path !== '')
|
|
48
|
+
.map(path => path.replace(/^\.\//, ''));
|
|
49
|
+
}
|
|
50
|
+
async findMatches(pattern, ignoreCase, signal, keep) {
|
|
51
|
+
// ripgrep ends every search it ran with a summary. A failure before searching, such as a
|
|
52
|
+
// pattern that does not compile, prints none. A count, not a flag: TypeScript cannot see
|
|
53
|
+
// the callback change it.
|
|
54
|
+
let summaries = 0;
|
|
55
|
+
const { stderr, exitCode } = await this.ripgrep(['--json', ...(ignoreCase ? ['--ignore-case'] : []), '-e', pattern], signal, line => {
|
|
56
|
+
if (line === '') {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const event = JSON.parse(line);
|
|
60
|
+
if (event.type === 'summary') {
|
|
61
|
+
summaries++;
|
|
62
|
+
}
|
|
63
|
+
if (event.type === 'match') {
|
|
64
|
+
keep({
|
|
65
|
+
path: decode(event.data.path).replace(/^\.\//, ''),
|
|
66
|
+
line: event.data.line_number,
|
|
67
|
+
text: decode(event.data.lines).replace(/\r?\n$/, ''),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
// 1 means no match. 2 after a search means some file could not be read, and the rest
|
|
72
|
+
// stands, as it does on git; 2 with no search is the failure.
|
|
73
|
+
if (exitCode > 1 && summaries === 0) {
|
|
74
|
+
throw new Error(`ripgrep could not search for the pattern: ${firstLine(stderr)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
ripgrep(args, signal, onLine) {
|
|
78
|
+
return runProcess(this.ripgrepPath, [...sameViewAsGit, ...args], {
|
|
79
|
+
cwd: this.root,
|
|
80
|
+
signal,
|
|
81
|
+
onLine,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=ripgrepWorkspace.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ripgrepWorkspace.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/ripgrepWorkspace.ts"],"names":[],"mappings":"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","sourcesContent":["import type { TextMatch } from '../domain/workspace.ts';\nimport { BoundedWorkspace } from './boundedWorkspace.ts';\nimport { firstLine, runProcess } from './runProcess.ts';\n\n// Every call sees what git would. Each flag is ours; nothing a model writes ever reaches the\n// command line except the pattern, and that only after `-e`.\nconst sameViewAsGit = [\n\t// A user's ripgrep configuration could switch the ignore rules off.\n\t'--no-config',\n\t// ripgrep's own `.ignore` and `.rgignore` outrank `.gitignore`, so a `!.env` line in either\n\t// would show a secret git hides. Only git's ignore sources count.\n\t'--no-ignore-dot',\n\t// Hidden files such as `.github/` are part of the repository; ripgrep skips them by default.\n\t'--hidden',\n\t// `.gitignore` applies even outside a git repository.\n\t'--no-require-git',\n\t// `--hidden` would otherwise search git's own folder.\n\t'--glob',\n\t'!.git',\n\t// Windows would print `\\`; every path in a `Workspace` is written with `/`.\n\t'--path-separator',\n\t'/',\n];\n\ninterface RipgrepText {\n\ttext?: string;\n\t// ripgrep sends what is not valid UTF-8 as base64 instead.\n\tbytes?: string;\n}\n\ninterface RipgrepEvent {\n\ttype: string;\n\tdata: { path: RipgrepText; line_number: number; lines: RipgrepText };\n}\n\nfunction decode({ text, bytes }: RipgrepText): string {\n\treturn text ?? Buffer.from(bytes ?? '', 'base64').toString('utf8');\n}\n\n/**\n * The preferred `Workspace`, on the ripgrep binary that ships with the package. It always runs\n * from the root with no path: ripgrep ignores nothing that it is given explicitly.\n *\n * It takes the binary's path instead of importing `@vscode/ripgrep`, which throws on import when\n * the platform has no binary; `createWorkspace` loads it where that failure can be handled.\n */\nexport class RipgrepWorkspace extends BoundedWorkspace {\n\tprivate readonly ripgrepPath: string;\n\n\tconstructor({\n\t\tripgrepPath,\n\t\t...options\n\t}: {\n\t\troot: string;\n\t\tripgrepPath: string;\n\t\ttimeoutMs?: number;\n\t}) {\n\t\tsuper(options);\n\t\tthis.ripgrepPath = ripgrepPath;\n\t}\n\n\tprotected async listCandidates(signal: AbortSignal): Promise<string[]> {\n\t\tconst { stdout, stderr, exitCode } = await this.ripgrep(['--files', '--null'], signal);\n\t\t// 1 means nothing to list, which an empty repository is. 2 with a listing means some\n\t\t// folder could not be read, which git skips with a warning too; without one, it failed.\n\t\tif (exitCode > 1 && stdout === '') {\n\t\t\tthrow new Error(`ripgrep could not list the files: ${firstLine(stderr)}`);\n\t\t}\n\n\t\treturn stdout\n\t\t\t.split('\\0')\n\t\t\t.filter(path => path !== '')\n\t\t\t.map(path => path.replace(/^\\.\\//, ''));\n\t}\n\n\tprotected async findMatches(\n\t\tpattern: string,\n\t\tignoreCase: boolean,\n\t\tsignal: AbortSignal,\n\t\tkeep: (match: TextMatch) => void,\n\t): Promise<void> {\n\t\t// ripgrep ends every search it ran with a summary. A failure before searching, such as a\n\t\t// pattern that does not compile, prints none. A count, not a flag: TypeScript cannot see\n\t\t// the callback change it.\n\t\tlet summaries = 0;\n\t\tconst { stderr, exitCode } = await this.ripgrep(\n\t\t\t['--json', ...(ignoreCase ? ['--ignore-case'] : []), '-e', pattern],\n\t\t\tsignal,\n\t\t\tline => {\n\t\t\t\tif (line === '') {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst event = JSON.parse(line) as RipgrepEvent;\n\t\t\t\tif (event.type === 'summary') {\n\t\t\t\t\tsummaries++;\n\t\t\t\t}\n\t\t\t\tif (event.type === 'match') {\n\t\t\t\t\tkeep({\n\t\t\t\t\t\tpath: decode(event.data.path).replace(/^\\.\\//, ''),\n\t\t\t\t\t\tline: event.data.line_number,\n\t\t\t\t\t\ttext: decode(event.data.lines).replace(/\\r?\\n$/, ''),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t},\n\t\t);\n\n\t\t// 1 means no match. 2 after a search means some file could not be read, and the rest\n\t\t// stands, as it does on git; 2 with no search is the failure.\n\t\tif (exitCode > 1 && summaries === 0) {\n\t\t\tthrow new Error(`ripgrep could not search for the pattern: ${firstLine(stderr)}`);\n\t\t}\n\t}\n\n\tprivate ripgrep(args: string[], signal: AbortSignal, onLine?: (line: string) => void) {\n\t\treturn runProcess(this.ripgrepPath, [...sameViewAsGit, ...args], {\n\t\t\tcwd: this.root,\n\t\t\tsignal,\n\t\t\tonLine,\n\t\t});\n\t}\n}\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface ProcessResult {
|
|
2
|
+
/** Empty when the output went to `onLine` instead. */
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
exitCode: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Runs a program with its arguments as an array, never through a shell, so nothing a model
|
|
9
|
+
* writes can become a command. Standard input is closed: ripgrep given no path searches its
|
|
10
|
+
* standard input whenever that is a pipe. A cancelled signal kills the process and rejects
|
|
11
|
+
* with the signal's `AbortError`.
|
|
12
|
+
*
|
|
13
|
+
* With `onLine`, each line of the output is handed over as it arrives and nothing is kept, so
|
|
14
|
+
* the caller decides what to hold; only a single line is then bound by the size limit. An
|
|
15
|
+
* error thrown by `onLine` stops the program and rejects with that error.
|
|
16
|
+
*/
|
|
17
|
+
export declare function runProcess(command: string, args: string[], { cwd, signal, onLine }: {
|
|
18
|
+
cwd: string;
|
|
19
|
+
signal: AbortSignal;
|
|
20
|
+
onLine?: (line: string) => void;
|
|
21
|
+
}): Promise<ProcessResult>;
|
|
22
|
+
/** The first line of a program's error output, which is the part a model can act on. */
|
|
23
|
+
export declare function firstLine(text: string): string;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
+
// What a program may print, or one line of it may hold, before it is stopped. Beyond this it
|
|
4
|
+
// has printed far more than anyone can read.
|
|
5
|
+
const maxOutputBytes = 64 * 1024 * 1024;
|
|
6
|
+
/**
|
|
7
|
+
* Runs a program with its arguments as an array, never through a shell, so nothing a model
|
|
8
|
+
* writes can become a command. Standard input is closed: ripgrep given no path searches its
|
|
9
|
+
* standard input whenever that is a pipe. A cancelled signal kills the process and rejects
|
|
10
|
+
* with the signal's `AbortError`.
|
|
11
|
+
*
|
|
12
|
+
* With `onLine`, each line of the output is handed over as it arrives and nothing is kept, so
|
|
13
|
+
* the caller decides what to hold; only a single line is then bound by the size limit. An
|
|
14
|
+
* error thrown by `onLine` stops the program and rejects with that error.
|
|
15
|
+
*/
|
|
16
|
+
export function runProcess(command, args, { cwd, signal, onLine }) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const child = spawn(command, args, { cwd, signal, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
19
|
+
const decoder = new StringDecoder('utf8');
|
|
20
|
+
const stdout = [];
|
|
21
|
+
const stderr = [];
|
|
22
|
+
let pending = '';
|
|
23
|
+
let size = 0;
|
|
24
|
+
let failure;
|
|
25
|
+
const stop = (error) => {
|
|
26
|
+
failure ??= error;
|
|
27
|
+
child.kill();
|
|
28
|
+
};
|
|
29
|
+
const deliver = (text) => {
|
|
30
|
+
if (!onLine) {
|
|
31
|
+
stdout.push(text);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const lines = (pending + text).split('\n');
|
|
35
|
+
pending = lines.pop() ?? '';
|
|
36
|
+
for (const line of lines)
|
|
37
|
+
onLine(line);
|
|
38
|
+
};
|
|
39
|
+
child.stdout.on('data', (chunk) => {
|
|
40
|
+
if (failure) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
size = onLine ? pending.length + chunk.length : size + chunk.length;
|
|
44
|
+
if (size > maxOutputBytes) {
|
|
45
|
+
stop(new Error(`The program printed more than ${String(maxOutputBytes / 1024 / 1024)} MB`));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
deliver(decoder.write(chunk));
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
stop(error instanceof Error ? error : new Error(String(error)));
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
56
|
+
// A missing program, or the cancellation, which arrives as an `AbortError`.
|
|
57
|
+
child.on('error', reject);
|
|
58
|
+
child.on('close', (code, killedBy) => {
|
|
59
|
+
// Stopped from outside, such as by the out-of-memory killer: what it printed is not the
|
|
60
|
+
// whole answer, and an exit code would pass it off as one. Its own stops set `failure`.
|
|
61
|
+
if (code === null && !failure) {
|
|
62
|
+
failure = new Error(`${command} was stopped by ${killedBy ?? 'a signal'} before it finished`);
|
|
63
|
+
}
|
|
64
|
+
if (!failure) {
|
|
65
|
+
try {
|
|
66
|
+
deliver(decoder.end());
|
|
67
|
+
if (onLine && pending !== '')
|
|
68
|
+
onLine(pending);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (failure) {
|
|
75
|
+
reject(failure);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
resolve({
|
|
79
|
+
stdout: stdout.join(''),
|
|
80
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
81
|
+
// Not null here: a process stopped by a signal was rejected above.
|
|
82
|
+
exitCode: code ?? 1,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** The first line of a program's error output, which is the part a model can act on. */
|
|
88
|
+
export function firstLine(text) {
|
|
89
|
+
return text.trim().split('\n')[0] ?? '';
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=runProcess.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runProcess.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/runProcess.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from 'node:child_process';\nimport { StringDecoder } from 'node:string_decoder';\n\n// What a program may print, or one line of it may hold, before it is stopped. Beyond this it\n// has printed far more than anyone can read.\nconst maxOutputBytes = 64 * 1024 * 1024;\n\nexport interface ProcessResult {\n\t/** Empty when the output went to `onLine` instead. */\n\tstdout: string;\n\tstderr: string;\n\texitCode: number;\n}\n\n/**\n * Runs a program with its arguments as an array, never through a shell, so nothing a model\n * writes can become a command. Standard input is closed: ripgrep given no path searches its\n * standard input whenever that is a pipe. A cancelled signal kills the process and rejects\n * with the signal's `AbortError`.\n *\n * With `onLine`, each line of the output is handed over as it arrives and nothing is kept, so\n * the caller decides what to hold; only a single line is then bound by the size limit. An\n * error thrown by `onLine` stops the program and rejects with that error.\n */\nexport function runProcess(\n\tcommand: string,\n\targs: string[],\n\t{ cwd, signal, onLine }: { cwd: string; signal: AbortSignal; onLine?: (line: string) => void },\n): Promise<ProcessResult> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst child = spawn(command, args, { cwd, signal, stdio: ['ignore', 'pipe', 'pipe'] });\n\t\tconst decoder = new StringDecoder('utf8');\n\t\tconst stdout: string[] = [];\n\t\tconst stderr: Buffer[] = [];\n\t\tlet pending = '';\n\t\tlet size = 0;\n\t\tlet failure: Error | undefined;\n\n\t\tconst stop = (error: Error) => {\n\t\t\tfailure ??= error;\n\t\t\tchild.kill();\n\t\t};\n\n\t\tconst deliver = (text: string) => {\n\t\t\tif (!onLine) {\n\t\t\t\tstdout.push(text);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst lines = (pending + text).split('\\n');\n\t\t\tpending = lines.pop() ?? '';\n\t\t\tfor (const line of lines) onLine(line);\n\t\t};\n\n\t\tchild.stdout.on('data', (chunk: Buffer) => {\n\t\t\tif (failure) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsize = onLine ? pending.length + chunk.length : size + chunk.length;\n\t\t\tif (size > maxOutputBytes) {\n\t\t\t\tstop(new Error(`The program printed more than ${String(maxOutputBytes / 1024 / 1024)} MB`));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tdeliver(decoder.write(chunk));\n\t\t\t} catch (error) {\n\t\t\t\tstop(error instanceof Error ? error : new Error(String(error)));\n\t\t\t}\n\t\t});\n\t\tchild.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));\n\n\t\t// A missing program, or the cancellation, which arrives as an `AbortError`.\n\t\tchild.on('error', reject);\n\t\tchild.on('close', (code, killedBy) => {\n\t\t\t// Stopped from outside, such as by the out-of-memory killer: what it printed is not the\n\t\t\t// whole answer, and an exit code would pass it off as one. Its own stops set `failure`.\n\t\t\tif (code === null && !failure) {\n\t\t\t\tfailure = new Error(\n\t\t\t\t\t`${command} was stopped by ${killedBy ?? 'a signal'} before it finished`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (!failure) {\n\t\t\t\ttry {\n\t\t\t\t\tdeliver(decoder.end());\n\t\t\t\t\tif (onLine && pending !== '') onLine(pending);\n\t\t\t\t} catch (error) {\n\t\t\t\t\tfailure = error instanceof Error ? error : new Error(String(error));\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (failure) {\n\t\t\t\treject(failure);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tresolve({\n\t\t\t\tstdout: stdout.join(''),\n\t\t\t\tstderr: Buffer.concat(stderr).toString('utf8'),\n\t\t\t\t// Not null here: a process stopped by a signal was rejected above.\n\t\t\t\texitCode: code ?? 1,\n\t\t\t});\n\t\t});\n\t});\n}\n\n/** The first line of a program's error output, which is the part a model can act on. */\nexport function firstLine(text: string): string {\n\treturn text.trim().split('\\n')[0] ?? '';\n}\n"]}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from "./defineTool.js";
|
|
3
|
+
// What one call may return. The model can ask for fewer lines, never for more.
|
|
4
|
+
const maxFiles = 200;
|
|
5
|
+
const maxMatches = 100;
|
|
6
|
+
const maxLines = 200;
|
|
7
|
+
// One minified line can be larger than everything else the model has read.
|
|
8
|
+
const maxLineLength = 300;
|
|
9
|
+
// Strict mode has no optional fields: an argument the model may leave out is nullable.
|
|
10
|
+
const scope = {
|
|
11
|
+
path: z.string().nullable().describe('A folder or file to stay inside, relative to the root.'),
|
|
12
|
+
glob: z.string().nullable().describe('Only paths matching this glob, such as "**/*.ts".'),
|
|
13
|
+
};
|
|
14
|
+
/** One line as the model reads it: a minified bundle on a single line would flood its context. */
|
|
15
|
+
function cutLine(text) {
|
|
16
|
+
return text.length > maxLineLength ? `${text.slice(0, maxLineLength)}…` : text;
|
|
17
|
+
}
|
|
18
|
+
function assertPositiveInteger(name, value) {
|
|
19
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
20
|
+
throw new Error(`${name} must be a whole number of at least 1; got ${String(value)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The three read-only tools an agent explores a repository with. */
|
|
24
|
+
export function createWorkspaceTools(workspace) {
|
|
25
|
+
return [
|
|
26
|
+
defineTool({
|
|
27
|
+
name: 'listFiles',
|
|
28
|
+
description: 'Lists the files of the repository, sorted. Ignored files such as dependencies are left out.',
|
|
29
|
+
input: z.object(scope),
|
|
30
|
+
execute: async ({ path, glob }, signal) => {
|
|
31
|
+
// The port takes "no scope" as a missing field.
|
|
32
|
+
const { files, total, truncated } = await workspace.listFiles({ path: path ?? undefined, glob: glob ?? undefined, limit: maxFiles }, signal);
|
|
33
|
+
// Nothing found is an answer too, not an empty string.
|
|
34
|
+
if (files.length === 0) {
|
|
35
|
+
return 'No files.';
|
|
36
|
+
}
|
|
37
|
+
const notice = truncated
|
|
38
|
+
? `\n[Showing ${String(files.length)} of ${String(total)} files. Narrow the path or glob to see the rest.]`
|
|
39
|
+
: '';
|
|
40
|
+
return files.join('\n') + notice;
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
defineTool({
|
|
44
|
+
name: 'searchText',
|
|
45
|
+
description: 'Searches the repository for a regular expression and returns each matching line as path:line: text.',
|
|
46
|
+
input: z.object({
|
|
47
|
+
pattern: z.string().describe('A regular expression.'),
|
|
48
|
+
ignoreCase: z.boolean().nullable().describe('Match regardless of case. Defaults to false.'),
|
|
49
|
+
...scope,
|
|
50
|
+
}),
|
|
51
|
+
execute: async ({ path, glob, ignoreCase, pattern }, signal) => {
|
|
52
|
+
const { matches, total, truncated } = await workspace.searchText({
|
|
53
|
+
path: path ?? undefined,
|
|
54
|
+
glob: glob ?? undefined,
|
|
55
|
+
ignoreCase: ignoreCase ?? false,
|
|
56
|
+
pattern,
|
|
57
|
+
limit: maxMatches,
|
|
58
|
+
}, signal);
|
|
59
|
+
if (matches.length === 0) {
|
|
60
|
+
return 'No matches.';
|
|
61
|
+
}
|
|
62
|
+
const results = matches.map(match => `${match.path}:${String(match.line)}: ${cutLine(match.text)}`);
|
|
63
|
+
const notice = truncated
|
|
64
|
+
? `\n[Showing ${String(results.length)} of ${String(total)} matches. Narrow the path or glob to see the rest.]`
|
|
65
|
+
: '';
|
|
66
|
+
return results.join('\n') + notice;
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
defineTool({
|
|
70
|
+
name: 'readFile',
|
|
71
|
+
description: 'Reads lines of a file, each prefixed with its number.',
|
|
72
|
+
input: z.object({
|
|
73
|
+
path: z.string().describe('The file, relative to the root.'),
|
|
74
|
+
fromLine: z.number().nullable().describe('The first line to read, from 1. Defaults to 1.'),
|
|
75
|
+
lineCount: z
|
|
76
|
+
.number()
|
|
77
|
+
.nullable()
|
|
78
|
+
.describe(`How many lines to read, at most ${String(maxLines)}. Defaults to ${String(maxLines)}.`),
|
|
79
|
+
}),
|
|
80
|
+
execute: async ({ fromLine, lineCount, path }, signal) => {
|
|
81
|
+
const from = fromLine ?? 1;
|
|
82
|
+
const requested = lineCount ?? maxLines;
|
|
83
|
+
// Checked here, before reading, because strict mode allows the schema no minimum.
|
|
84
|
+
assertPositiveInteger('fromLine', from);
|
|
85
|
+
assertPositiveInteger('lineCount', requested);
|
|
86
|
+
const { lines, totalLines, truncated } = await workspace.readFile({ path, fromLine: from, lineCount: Math.min(requested, maxLines) }, signal);
|
|
87
|
+
// The file's length tells the model where its range went wrong.
|
|
88
|
+
if (lines.length === 0) {
|
|
89
|
+
return `No lines from ${String(from)}: the file has ${String(totalLines)} lines.`;
|
|
90
|
+
}
|
|
91
|
+
const numbered = lines.map((text, index) => `${String(from + index)}: ${cutLine(text)}`);
|
|
92
|
+
const cut = lines.filter(text => text.length > maxLineLength).length;
|
|
93
|
+
const notices = [
|
|
94
|
+
...(cut > 0
|
|
95
|
+
? [
|
|
96
|
+
`[${String(cut)} lines were longer than ${String(maxLineLength)} characters and were cut.]`,
|
|
97
|
+
]
|
|
98
|
+
: []),
|
|
99
|
+
...(truncated
|
|
100
|
+
? [
|
|
101
|
+
`[The file has ${String(totalLines)} lines. Read from line ${String(from + lines.length)} to continue.]`,
|
|
102
|
+
]
|
|
103
|
+
: []),
|
|
104
|
+
];
|
|
105
|
+
return [...numbered, ...notices].join('\n');
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=workspaceTools.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspaceTools.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/workspaceTools.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from 'zod';\nimport type { Tool } from '../domain/tool.ts';\nimport type { Workspace } from '../domain/workspace.ts';\nimport { defineTool } from './defineTool.ts';\n\n// What one call may return. The model can ask for fewer lines, never for more.\nconst maxFiles = 200;\nconst maxMatches = 100;\nconst maxLines = 200;\n// One minified line can be larger than everything else the model has read.\nconst maxLineLength = 300;\n\n// Strict mode has no optional fields: an argument the model may leave out is nullable.\nconst scope = {\n\tpath: z.string().nullable().describe('A folder or file to stay inside, relative to the root.'),\n\tglob: z.string().nullable().describe('Only paths matching this glob, such as \"**/*.ts\".'),\n};\n\n/** One line as the model reads it: a minified bundle on a single line would flood its context. */\nfunction cutLine(text: string): string {\n\treturn text.length > maxLineLength ? `${text.slice(0, maxLineLength)}…` : text;\n}\n\nfunction assertPositiveInteger(name: string, value: number): void {\n\tif (!Number.isInteger(value) || value < 1) {\n\t\tthrow new Error(`${name} must be a whole number of at least 1; got ${String(value)}`);\n\t}\n}\n\n/** The three read-only tools an agent explores a repository with. */\nexport function createWorkspaceTools(workspace: Workspace): Tool[] {\n\treturn [\n\t\tdefineTool({\n\t\t\tname: 'listFiles',\n\t\t\tdescription:\n\t\t\t\t'Lists the files of the repository, sorted. Ignored files such as dependencies are left out.',\n\t\t\tinput: z.object(scope),\n\t\t\texecute: async ({ path, glob }, signal) => {\n\t\t\t\t// The port takes \"no scope\" as a missing field.\n\t\t\t\tconst { files, total, truncated } = await workspace.listFiles(\n\t\t\t\t\t{ path: path ?? undefined, glob: glob ?? undefined, limit: maxFiles },\n\t\t\t\t\tsignal,\n\t\t\t\t);\n\n\t\t\t\t// Nothing found is an answer too, not an empty string.\n\t\t\t\tif (files.length === 0) {\n\t\t\t\t\treturn 'No files.';\n\t\t\t\t}\n\n\t\t\t\tconst notice = truncated\n\t\t\t\t\t? `\\n[Showing ${String(files.length)} of ${String(total)} files. Narrow the path or glob to see the rest.]`\n\t\t\t\t\t: '';\n\t\t\t\treturn files.join('\\n') + notice;\n\t\t\t},\n\t\t}),\n\n\t\tdefineTool({\n\t\t\tname: 'searchText',\n\t\t\tdescription:\n\t\t\t\t'Searches the repository for a regular expression and returns each matching line as path:line: text.',\n\t\t\tinput: z.object({\n\t\t\t\tpattern: z.string().describe('A regular expression.'),\n\t\t\t\tignoreCase: z.boolean().nullable().describe('Match regardless of case. Defaults to false.'),\n\t\t\t\t...scope,\n\t\t\t}),\n\t\t\texecute: async ({ path, glob, ignoreCase, pattern }, signal) => {\n\t\t\t\tconst { matches, total, truncated } = await workspace.searchText(\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: path ?? undefined,\n\t\t\t\t\t\tglob: glob ?? undefined,\n\t\t\t\t\t\tignoreCase: ignoreCase ?? false,\n\t\t\t\t\t\tpattern,\n\t\t\t\t\t\tlimit: maxMatches,\n\t\t\t\t\t},\n\t\t\t\t\tsignal,\n\t\t\t\t);\n\n\t\t\t\tif (matches.length === 0) {\n\t\t\t\t\treturn 'No matches.';\n\t\t\t\t}\n\n\t\t\t\tconst results = matches.map(\n\t\t\t\t\tmatch => `${match.path}:${String(match.line)}: ${cutLine(match.text)}`,\n\t\t\t\t);\n\n\t\t\t\tconst notice = truncated\n\t\t\t\t\t? `\\n[Showing ${String(results.length)} of ${String(total)} matches. Narrow the path or glob to see the rest.]`\n\t\t\t\t\t: '';\n\t\t\t\treturn results.join('\\n') + notice;\n\t\t\t},\n\t\t}),\n\n\t\tdefineTool({\n\t\t\tname: 'readFile',\n\t\t\tdescription: 'Reads lines of a file, each prefixed with its number.',\n\t\t\tinput: z.object({\n\t\t\t\tpath: z.string().describe('The file, relative to the root.'),\n\t\t\t\tfromLine: z.number().nullable().describe('The first line to read, from 1. Defaults to 1.'),\n\t\t\t\tlineCount: z\n\t\t\t\t\t.number()\n\t\t\t\t\t.nullable()\n\t\t\t\t\t.describe(\n\t\t\t\t\t\t`How many lines to read, at most ${String(maxLines)}. Defaults to ${String(maxLines)}.`,\n\t\t\t\t\t),\n\t\t\t}),\n\t\t\texecute: async ({ fromLine, lineCount, path }, signal) => {\n\t\t\t\tconst from = fromLine ?? 1;\n\t\t\t\tconst requested = lineCount ?? maxLines;\n\t\t\t\t// Checked here, before reading, because strict mode allows the schema no minimum.\n\t\t\t\tassertPositiveInteger('fromLine', from);\n\t\t\t\tassertPositiveInteger('lineCount', requested);\n\n\t\t\t\tconst { lines, totalLines, truncated } = await workspace.readFile(\n\t\t\t\t\t{ path, fromLine: from, lineCount: Math.min(requested, maxLines) },\n\t\t\t\t\tsignal,\n\t\t\t\t);\n\n\t\t\t\t// The file's length tells the model where its range went wrong.\n\t\t\t\tif (lines.length === 0) {\n\t\t\t\t\treturn `No lines from ${String(from)}: the file has ${String(totalLines)} lines.`;\n\t\t\t\t}\n\n\t\t\t\tconst numbered = lines.map((text, index) => `${String(from + index)}: ${cutLine(text)}`);\n\t\t\t\tconst cut = lines.filter(text => text.length > maxLineLength).length;\n\t\t\t\tconst notices = [\n\t\t\t\t\t...(cut > 0\n\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t`[${String(cut)} lines were longer than ${String(maxLineLength)} characters and were cut.]`,\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t: []),\n\t\t\t\t\t...(truncated\n\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t`[The file has ${String(totalLines)} lines. Read from line ${String(from + lines.length)} to continue.]`,\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t: []),\n\t\t\t\t];\n\t\t\t\treturn [...numbered, ...notices].join('\\n');\n\t\t\t},\n\t\t}),\n\t];\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mikode13/harness",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Test/agent harness utility",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
|
64
64
|
"@anthropic-ai/sdk": "^0.115.0",
|
|
65
65
|
"@openai/codex-sdk": "^0.154.0",
|
|
66
|
+
"@vscode/ripgrep": "^1.18.0",
|
|
66
67
|
"openai": "^7.23.0",
|
|
67
68
|
"zod": "^4.4.3"
|
|
68
69
|
}
|