@poncho-ai/harness 0.2.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/.turbo/turbo-build.log +14 -0
- package/.turbo/turbo-test.log +22 -0
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/dist/index.d.ts +416 -0
- package/dist/index.js +3015 -0
- package/package.json +53 -0
- package/src/agent-parser.ts +127 -0
- package/src/anthropic-client.ts +134 -0
- package/src/config.ts +141 -0
- package/src/default-tools.ts +89 -0
- package/src/harness.ts +522 -0
- package/src/index.ts +17 -0
- package/src/latitude-capture.ts +108 -0
- package/src/local-tools.ts +108 -0
- package/src/mcp.ts +287 -0
- package/src/memory.ts +700 -0
- package/src/model-client.ts +44 -0
- package/src/model-factory.ts +14 -0
- package/src/openai-client.ts +169 -0
- package/src/skill-context.ts +259 -0
- package/src/skill-tools.ts +357 -0
- package/src/state.ts +1017 -0
- package/src/telemetry.ts +108 -0
- package/src/tool-dispatcher.ts +69 -0
- package/test/agent-parser.test.ts +39 -0
- package/test/harness.test.ts +716 -0
- package/test/mcp.test.ts +82 -0
- package/test/memory.test.ts +50 -0
- package/test/model-factory.test.ts +16 -0
- package/test/state.test.ts +43 -0
- package/test/telemetry.test.ts +57 -0
- package/tsconfig.json +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@poncho-ai/harness",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/latitude-dev/poncho-ai.git",
|
|
8
|
+
"directory": "packages/harness"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@anthropic-ai/sdk": "^0.74.0",
|
|
24
|
+
"@aws-sdk/client-dynamodb": "^3.988.0",
|
|
25
|
+
"@latitude-data/telemetry": "^2.0.2",
|
|
26
|
+
"jiti": "^2.6.1",
|
|
27
|
+
"mustache": "^4.2.0",
|
|
28
|
+
"openai": "^6.3.0",
|
|
29
|
+
"redis": "^5.10.0",
|
|
30
|
+
"ws": "^8.18.0",
|
|
31
|
+
"yaml": "^2.4.0",
|
|
32
|
+
"@poncho-ai/sdk": "0.2.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/mustache": "^4.2.6",
|
|
36
|
+
"@types/ws": "^8.18.1",
|
|
37
|
+
"tsup": "^8.0.0",
|
|
38
|
+
"vitest": "^1.4.0"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"ai",
|
|
42
|
+
"agent",
|
|
43
|
+
"harness",
|
|
44
|
+
"runtime"
|
|
45
|
+
],
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsup src/index.ts --format esm --dts",
|
|
49
|
+
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
50
|
+
"test": "vitest",
|
|
51
|
+
"lint": "eslint src/"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import Mustache from "mustache";
|
|
5
|
+
import YAML from "yaml";
|
|
6
|
+
|
|
7
|
+
export interface AgentModelConfig {
|
|
8
|
+
provider: string;
|
|
9
|
+
name: string;
|
|
10
|
+
temperature?: number;
|
|
11
|
+
maxTokens?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AgentLimitsConfig {
|
|
15
|
+
maxSteps?: number;
|
|
16
|
+
timeout?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AgentFrontmatter {
|
|
20
|
+
name: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
model?: AgentModelConfig;
|
|
23
|
+
limits?: AgentLimitsConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ParsedAgent {
|
|
27
|
+
frontmatter: AgentFrontmatter;
|
|
28
|
+
body: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RuntimeRenderContext {
|
|
32
|
+
parameters?: Record<string, unknown>;
|
|
33
|
+
runtime?: {
|
|
34
|
+
workingDir?: string;
|
|
35
|
+
agentId?: string;
|
|
36
|
+
runId?: string;
|
|
37
|
+
environment?: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const FRONTMATTER_PATTERN = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/;
|
|
42
|
+
|
|
43
|
+
const asRecord = (value: unknown): Record<string, unknown> =>
|
|
44
|
+
typeof value === "object" && value !== null
|
|
45
|
+
? (value as Record<string, unknown>)
|
|
46
|
+
: {};
|
|
47
|
+
|
|
48
|
+
const asNumberOrUndefined = (value: unknown): number | undefined =>
|
|
49
|
+
typeof value === "number" ? value : undefined;
|
|
50
|
+
|
|
51
|
+
export const parseAgentMarkdown = (content: string): ParsedAgent => {
|
|
52
|
+
const match = content.match(FRONTMATTER_PATTERN);
|
|
53
|
+
|
|
54
|
+
if (!match) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"Invalid AGENT.md: expected YAML frontmatter wrapped in --- markers.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const parsedYaml = YAML.parse(match[1]) ?? {};
|
|
61
|
+
const parsed = asRecord(parsedYaml);
|
|
62
|
+
|
|
63
|
+
if (typeof parsed.name !== "string" || parsed.name.trim() === "") {
|
|
64
|
+
throw new Error("Invalid AGENT.md: frontmatter requires a non-empty `name`.");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const modelValue = asRecord(parsed.model);
|
|
68
|
+
const limitsValue = asRecord(parsed.limits);
|
|
69
|
+
|
|
70
|
+
const frontmatter: AgentFrontmatter = {
|
|
71
|
+
name: parsed.name,
|
|
72
|
+
description:
|
|
73
|
+
typeof parsed.description === "string" ? parsed.description : undefined,
|
|
74
|
+
model:
|
|
75
|
+
Object.keys(modelValue).length > 0
|
|
76
|
+
? {
|
|
77
|
+
provider:
|
|
78
|
+
typeof modelValue.provider === "string"
|
|
79
|
+
? modelValue.provider
|
|
80
|
+
: "anthropic",
|
|
81
|
+
name:
|
|
82
|
+
typeof modelValue.name === "string"
|
|
83
|
+
? modelValue.name
|
|
84
|
+
: "claude-opus-4-5",
|
|
85
|
+
temperature: asNumberOrUndefined(modelValue.temperature),
|
|
86
|
+
maxTokens: asNumberOrUndefined(modelValue.maxTokens),
|
|
87
|
+
}
|
|
88
|
+
: undefined,
|
|
89
|
+
limits:
|
|
90
|
+
Object.keys(limitsValue).length > 0
|
|
91
|
+
? {
|
|
92
|
+
maxSteps: asNumberOrUndefined(limitsValue.maxSteps),
|
|
93
|
+
timeout: asNumberOrUndefined(limitsValue.timeout),
|
|
94
|
+
}
|
|
95
|
+
: undefined,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
frontmatter,
|
|
100
|
+
body: match[2].trim(),
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const parseAgentFile = async (workingDir: string): Promise<ParsedAgent> => {
|
|
105
|
+
const filePath = resolve(workingDir, "AGENT.md");
|
|
106
|
+
const content = await readFile(filePath, "utf8");
|
|
107
|
+
return parseAgentMarkdown(content);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const renderAgentPrompt = (
|
|
111
|
+
agent: ParsedAgent,
|
|
112
|
+
context: RuntimeRenderContext = {},
|
|
113
|
+
): string => {
|
|
114
|
+
const renderContext = {
|
|
115
|
+
name: agent.frontmatter.name,
|
|
116
|
+
description: agent.frontmatter.description ?? "",
|
|
117
|
+
runtime: {
|
|
118
|
+
workingDir: context.runtime?.workingDir ?? process.cwd(),
|
|
119
|
+
agentId: context.runtime?.agentId ?? agent.frontmatter.name,
|
|
120
|
+
runId: context.runtime?.runId ?? `run_${randomUUID()}`,
|
|
121
|
+
environment: context.runtime?.environment ?? "development",
|
|
122
|
+
},
|
|
123
|
+
parameters: context.parameters ?? {},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return Mustache.render(agent.body, renderContext).trim();
|
|
127
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import type { MessageParam } from "@anthropic-ai/sdk/resources/messages";
|
|
3
|
+
import type { Message } from "@poncho-ai/sdk";
|
|
4
|
+
import type {
|
|
5
|
+
ModelCallInput,
|
|
6
|
+
ModelClient,
|
|
7
|
+
ModelClientOptions,
|
|
8
|
+
ModelResponse,
|
|
9
|
+
ModelStreamEvent,
|
|
10
|
+
} from "./model-client.js";
|
|
11
|
+
|
|
12
|
+
const toAnthropicMessages = (messages: Message[]): MessageParam[] =>
|
|
13
|
+
messages.flatMap((message) => {
|
|
14
|
+
if (message.role === "system") {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
if (message.role === "tool") {
|
|
18
|
+
return [{ role: "user", content: message.content }];
|
|
19
|
+
}
|
|
20
|
+
return [{ role: message.role, content: message.content }];
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export class AnthropicModelClient implements ModelClient {
|
|
24
|
+
private readonly client: Anthropic;
|
|
25
|
+
private readonly latitudeCapture;
|
|
26
|
+
|
|
27
|
+
constructor(apiKey?: string, options?: ModelClientOptions) {
|
|
28
|
+
this.client = new Anthropic({
|
|
29
|
+
apiKey: apiKey ?? process.env.ANTHROPIC_API_KEY,
|
|
30
|
+
});
|
|
31
|
+
this.latitudeCapture = options?.latitudeCapture;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async *generateStream(input: ModelCallInput): AsyncGenerator<ModelStreamEvent> {
|
|
35
|
+
let stream;
|
|
36
|
+
try {
|
|
37
|
+
stream = await (this.latitudeCapture?.capture(async () =>
|
|
38
|
+
this.client.messages.stream({
|
|
39
|
+
model: input.modelName,
|
|
40
|
+
max_tokens: input.maxTokens ?? 1024,
|
|
41
|
+
temperature: input.temperature ?? 0.2,
|
|
42
|
+
system: input.systemPrompt,
|
|
43
|
+
messages: toAnthropicMessages(input.messages),
|
|
44
|
+
tools: input.tools.map((tool) => ({
|
|
45
|
+
name: tool.name,
|
|
46
|
+
description: tool.description,
|
|
47
|
+
input_schema: tool.inputSchema,
|
|
48
|
+
})),
|
|
49
|
+
}),
|
|
50
|
+
) ??
|
|
51
|
+
this.client.messages.stream({
|
|
52
|
+
model: input.modelName,
|
|
53
|
+
max_tokens: input.maxTokens ?? 1024,
|
|
54
|
+
temperature: input.temperature ?? 0.2,
|
|
55
|
+
system: input.systemPrompt,
|
|
56
|
+
messages: toAnthropicMessages(input.messages),
|
|
57
|
+
tools: input.tools.map((tool) => ({
|
|
58
|
+
name: tool.name,
|
|
59
|
+
description: tool.description,
|
|
60
|
+
input_schema: tool.inputSchema,
|
|
61
|
+
})),
|
|
62
|
+
}));
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const maybeStatus = (error as { status?: number }).status;
|
|
65
|
+
if (maybeStatus === 404) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Anthropic model not found: ${input.modelName}. Update AGENT.md frontmatter model.name to a valid model (for example: claude-opus-4-5).`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let text = "";
|
|
74
|
+
for await (const event of stream as AsyncIterable<{
|
|
75
|
+
type: string;
|
|
76
|
+
delta?: { type?: string; text?: string };
|
|
77
|
+
}>) {
|
|
78
|
+
if (
|
|
79
|
+
event.type === "content_block_delta" &&
|
|
80
|
+
event.delta?.type === "text_delta" &&
|
|
81
|
+
typeof event.delta.text === "string" &&
|
|
82
|
+
event.delta.text.length > 0
|
|
83
|
+
) {
|
|
84
|
+
text += event.delta.text;
|
|
85
|
+
yield { type: "chunk", content: event.delta.text };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const response = await (
|
|
90
|
+
stream as { finalMessage: () => Promise<Anthropic.Messages.Message> }
|
|
91
|
+
).finalMessage();
|
|
92
|
+
const toolCalls: ModelResponse["toolCalls"] = [];
|
|
93
|
+
for (const block of response.content) {
|
|
94
|
+
if (block.type === "text") {
|
|
95
|
+
if (text.length === 0 && block.text) {
|
|
96
|
+
text = block.text;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (block.type === "tool_use") {
|
|
100
|
+
toolCalls.push({
|
|
101
|
+
id: block.id,
|
|
102
|
+
name: block.name,
|
|
103
|
+
input: (block.input ?? {}) as Record<string, unknown>,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
yield {
|
|
109
|
+
type: "final",
|
|
110
|
+
response: {
|
|
111
|
+
text,
|
|
112
|
+
toolCalls,
|
|
113
|
+
usage: {
|
|
114
|
+
input: response.usage.input_tokens,
|
|
115
|
+
output: response.usage.output_tokens,
|
|
116
|
+
},
|
|
117
|
+
rawContent: response.content as unknown[],
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async generate(input: ModelCallInput): Promise<ModelResponse> {
|
|
123
|
+
let finalResponse: ModelResponse | undefined;
|
|
124
|
+
for await (const event of this.generateStream(input)) {
|
|
125
|
+
if (event.type === "final") {
|
|
126
|
+
finalResponse = event.response;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!finalResponse) {
|
|
130
|
+
throw new Error("Anthropic response ended without final payload");
|
|
131
|
+
}
|
|
132
|
+
return finalResponse;
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { MemoryConfig } from "./memory.js";
|
|
4
|
+
import type { McpConfig } from "./mcp.js";
|
|
5
|
+
import type { StateConfig } from "./state.js";
|
|
6
|
+
|
|
7
|
+
export interface StorageConfig {
|
|
8
|
+
provider?: "local" | "memory" | "redis" | "upstash" | "dynamodb";
|
|
9
|
+
url?: string;
|
|
10
|
+
token?: string;
|
|
11
|
+
table?: string;
|
|
12
|
+
region?: string;
|
|
13
|
+
ttl?:
|
|
14
|
+
| number
|
|
15
|
+
| {
|
|
16
|
+
conversations?: number;
|
|
17
|
+
memory?: number;
|
|
18
|
+
};
|
|
19
|
+
memory?: {
|
|
20
|
+
enabled?: boolean;
|
|
21
|
+
maxRecallConversations?: number;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type BuiltInToolToggles = {
|
|
26
|
+
list_directory?: boolean;
|
|
27
|
+
read_file?: boolean;
|
|
28
|
+
write_file?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export interface PonchoConfig extends McpConfig {
|
|
32
|
+
harness?: string;
|
|
33
|
+
tools?: {
|
|
34
|
+
defaults?: BuiltInToolToggles;
|
|
35
|
+
byEnvironment?: {
|
|
36
|
+
development?: BuiltInToolToggles;
|
|
37
|
+
staging?: BuiltInToolToggles;
|
|
38
|
+
production?: BuiltInToolToggles;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
auth?: {
|
|
42
|
+
required?: boolean;
|
|
43
|
+
type?: "bearer" | "header" | "custom";
|
|
44
|
+
headerName?: string;
|
|
45
|
+
validate?: (token: string, req?: unknown) => Promise<boolean> | boolean;
|
|
46
|
+
};
|
|
47
|
+
state?: {
|
|
48
|
+
provider?: "local" | "memory" | "redis" | "upstash" | "dynamodb";
|
|
49
|
+
ttl?: number;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
};
|
|
52
|
+
memory?: MemoryConfig;
|
|
53
|
+
storage?: StorageConfig;
|
|
54
|
+
telemetry?: {
|
|
55
|
+
enabled?: boolean;
|
|
56
|
+
otlp?: string;
|
|
57
|
+
latitude?: {
|
|
58
|
+
apiKey?: string;
|
|
59
|
+
projectId?: string | number;
|
|
60
|
+
path?: string;
|
|
61
|
+
documentPath?: string;
|
|
62
|
+
};
|
|
63
|
+
handler?: (event: unknown) => Promise<void> | void;
|
|
64
|
+
};
|
|
65
|
+
skills?: Record<string, Record<string, unknown>>;
|
|
66
|
+
/** Extra directories (relative to project root) to scan for skills.
|
|
67
|
+
* `skills/` and `.poncho/skills/` are always scanned. */
|
|
68
|
+
skillPaths?: string[];
|
|
69
|
+
build?: {
|
|
70
|
+
vercel?: Record<string, unknown>;
|
|
71
|
+
docker?: Record<string, unknown>;
|
|
72
|
+
lambda?: Record<string, unknown>;
|
|
73
|
+
fly?: Record<string, unknown>;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const resolveTtl = (
|
|
78
|
+
ttl: StorageConfig["ttl"] | undefined,
|
|
79
|
+
key: "conversations" | "memory",
|
|
80
|
+
): number | undefined => {
|
|
81
|
+
if (typeof ttl === "number") {
|
|
82
|
+
return ttl;
|
|
83
|
+
}
|
|
84
|
+
if (ttl && typeof ttl === "object" && typeof ttl[key] === "number") {
|
|
85
|
+
return ttl[key];
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const resolveStateConfig = (
|
|
91
|
+
config: PonchoConfig | undefined,
|
|
92
|
+
): StateConfig | undefined => {
|
|
93
|
+
if (config?.storage) {
|
|
94
|
+
return {
|
|
95
|
+
provider: config.storage.provider,
|
|
96
|
+
url: config.storage.url,
|
|
97
|
+
token: config.storage.token,
|
|
98
|
+
table: config.storage.table,
|
|
99
|
+
region: config.storage.region,
|
|
100
|
+
ttl: resolveTtl(config.storage.ttl, "conversations"),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return config?.state as StateConfig | undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const resolveMemoryConfig = (
|
|
107
|
+
config: PonchoConfig | undefined,
|
|
108
|
+
): MemoryConfig | undefined => {
|
|
109
|
+
if (config?.storage) {
|
|
110
|
+
return {
|
|
111
|
+
enabled: config.storage.memory?.enabled ?? config.memory?.enabled,
|
|
112
|
+
provider: config.storage.provider,
|
|
113
|
+
url: config.storage.url,
|
|
114
|
+
token: config.storage.token,
|
|
115
|
+
table: config.storage.table,
|
|
116
|
+
region: config.storage.region,
|
|
117
|
+
ttl: resolveTtl(config.storage.ttl, "memory"),
|
|
118
|
+
maxRecallConversations:
|
|
119
|
+
config.storage.memory?.maxRecallConversations ??
|
|
120
|
+
config.memory?.maxRecallConversations,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return config?.memory;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const loadPonchoConfig = async (
|
|
127
|
+
workingDir: string,
|
|
128
|
+
): Promise<PonchoConfig | undefined> => {
|
|
129
|
+
const filePath = resolve(workingDir, "poncho.config.js");
|
|
130
|
+
try {
|
|
131
|
+
await access(filePath);
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const imported = (await import(`${filePath}?t=${Date.now()}`)) as {
|
|
137
|
+
default?: PonchoConfig;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return imported.default;
|
|
141
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve, sep } from "node:path";
|
|
3
|
+
import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
|
|
4
|
+
|
|
5
|
+
const resolveSafePath = (workingDir: string, inputPath: string): string => {
|
|
6
|
+
const base = resolve(workingDir);
|
|
7
|
+
const target = resolve(base, inputPath);
|
|
8
|
+
if (target === base || target.startsWith(`${base}${sep}`)) {
|
|
9
|
+
return target;
|
|
10
|
+
}
|
|
11
|
+
throw new Error("Access denied: path must stay inside the working directory.");
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const createDefaultTools = (workingDir: string): ToolDefinition[] => [
|
|
15
|
+
defineTool({
|
|
16
|
+
name: "list_directory",
|
|
17
|
+
description: "List files and folders at a path",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
path: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Directory path relative to working directory",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
required: ["path"],
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
},
|
|
29
|
+
handler: async (input) => {
|
|
30
|
+
const path = typeof input.path === "string" ? input.path : ".";
|
|
31
|
+
const resolved = resolveSafePath(workingDir, path);
|
|
32
|
+
const entries = await readdir(resolved, { withFileTypes: true });
|
|
33
|
+
return entries.map((entry) => ({
|
|
34
|
+
name: entry.name,
|
|
35
|
+
type: entry.isDirectory() ? "directory" : "file",
|
|
36
|
+
}));
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
defineTool({
|
|
40
|
+
name: "read_file",
|
|
41
|
+
description: "Read UTF-8 text file contents",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
path: {
|
|
46
|
+
type: "string",
|
|
47
|
+
description: "File path relative to working directory",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
required: ["path"],
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
},
|
|
53
|
+
handler: async (input) => {
|
|
54
|
+
const path = typeof input.path === "string" ? input.path : "";
|
|
55
|
+
const resolved = resolveSafePath(workingDir, path);
|
|
56
|
+
const content = await readFile(resolved, "utf8");
|
|
57
|
+
return { path, content };
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export const createWriteTool = (workingDir: string): ToolDefinition =>
|
|
63
|
+
defineTool({
|
|
64
|
+
name: "write_file",
|
|
65
|
+
description: "Write UTF-8 text file contents (create or overwrite)",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
path: {
|
|
70
|
+
type: "string",
|
|
71
|
+
description: "File path relative to working directory",
|
|
72
|
+
},
|
|
73
|
+
content: {
|
|
74
|
+
type: "string",
|
|
75
|
+
description: "Text content to write",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ["path", "content"],
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
},
|
|
81
|
+
handler: async (input) => {
|
|
82
|
+
const path = typeof input.path === "string" ? input.path : "";
|
|
83
|
+
const content = typeof input.content === "string" ? input.content : "";
|
|
84
|
+
const resolved = resolveSafePath(workingDir, path);
|
|
85
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
86
|
+
await writeFile(resolved, content, "utf8");
|
|
87
|
+
return { path, written: true };
|
|
88
|
+
},
|
|
89
|
+
});
|