@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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { createJiti } from "jiti";
|
|
5
|
+
import type { ToolDefinition } from "@poncho-ai/sdk";
|
|
6
|
+
import { resolveSkillDirs } from "./skill-context.js";
|
|
7
|
+
|
|
8
|
+
const TOOL_FILE_PATTERN = /\.(?:[cm]?js|[cm]?ts)$/i;
|
|
9
|
+
|
|
10
|
+
const collectToolFiles = async (directory: string): Promise<string[]> => {
|
|
11
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
12
|
+
const files: string[] = [];
|
|
13
|
+
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const fullPath = resolve(directory, entry.name);
|
|
16
|
+
if (entry.isDirectory()) {
|
|
17
|
+
files.push(...(await collectToolFiles(fullPath)));
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (entry.isFile() && TOOL_FILE_PATTERN.test(entry.name)) {
|
|
21
|
+
files.push(fullPath);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return files;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const loadToolModule = async (filePath: string): Promise<unknown> => {
|
|
29
|
+
try {
|
|
30
|
+
const module = await import(pathToFileURL(filePath).href);
|
|
31
|
+
return module;
|
|
32
|
+
} catch {
|
|
33
|
+
try {
|
|
34
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
35
|
+
return await jiti.import(filePath);
|
|
36
|
+
} catch {
|
|
37
|
+
const source = await readFile(filePath, "utf8");
|
|
38
|
+
const shimmed = source.replace(
|
|
39
|
+
/import\s+\{\s*defineTool\s*\}\s+from\s+["']@poncho-ai\/(?:sdk|harness)["'];?\s*/g,
|
|
40
|
+
"const defineTool = (definition) => definition;\n",
|
|
41
|
+
);
|
|
42
|
+
const dataUrl = `data:text/javascript;base64,${Buffer.from(shimmed, "utf8").toString("base64")}`;
|
|
43
|
+
return await import(dataUrl);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const normalizeToolExports = (loaded: unknown): ToolDefinition[] => {
|
|
49
|
+
const module = loaded as {
|
|
50
|
+
default?: unknown;
|
|
51
|
+
tools?: unknown;
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
};
|
|
54
|
+
const candidates = [
|
|
55
|
+
module.default,
|
|
56
|
+
module.tools,
|
|
57
|
+
...Object.values(module).filter((value) => value && typeof value === "object"),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const toolDefinitions = candidates
|
|
61
|
+
.flatMap((value) => (Array.isArray(value) ? value : [value]))
|
|
62
|
+
.filter(
|
|
63
|
+
(value): value is ToolDefinition =>
|
|
64
|
+
Boolean(value) &&
|
|
65
|
+
typeof value === "object" &&
|
|
66
|
+
"name" in value &&
|
|
67
|
+
"description" in value &&
|
|
68
|
+
"inputSchema" in value &&
|
|
69
|
+
"handler" in value,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return toolDefinitions;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const loadLocalSkillTools = async (
|
|
76
|
+
workingDir: string,
|
|
77
|
+
extraSkillPaths?: string[],
|
|
78
|
+
): Promise<ToolDefinition[]> => {
|
|
79
|
+
const skillDirs = resolveSkillDirs(workingDir, extraSkillPaths);
|
|
80
|
+
const allToolFiles: string[] = [];
|
|
81
|
+
|
|
82
|
+
for (const dir of skillDirs) {
|
|
83
|
+
try {
|
|
84
|
+
allToolFiles.push(...(await collectToolFiles(dir)));
|
|
85
|
+
} catch {
|
|
86
|
+
// Directory doesn't exist or isn't readable — skip silently.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const tools: ToolDefinition[] = [];
|
|
91
|
+
for (const filePath of allToolFiles) {
|
|
92
|
+
try {
|
|
93
|
+
const loaded = await loadToolModule(filePath);
|
|
94
|
+
tools.push(...normalizeToolExports(loaded));
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
97
|
+
process.stderr.write(
|
|
98
|
+
`[poncho] Skipping skill tool module ${filePath}: ${message}\n`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const unique = new Map<string, ToolDefinition>();
|
|
104
|
+
for (const tool of tools) {
|
|
105
|
+
unique.set(tool.name, tool);
|
|
106
|
+
}
|
|
107
|
+
return [...unique.values()];
|
|
108
|
+
};
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@poncho-ai/sdk";
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
|
|
4
|
+
export interface RemoteMcpServerConfig {
|
|
5
|
+
name?: string;
|
|
6
|
+
url: string;
|
|
7
|
+
env?: string[];
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
reconnectAttempts?: number;
|
|
10
|
+
reconnectDelayMs?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface McpConfig {
|
|
14
|
+
mcp?: RemoteMcpServerConfig[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface McpToolDescriptor {
|
|
18
|
+
name: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
inputSchema?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface McpRpcClient {
|
|
24
|
+
listTools(): Promise<McpToolDescriptor[]>;
|
|
25
|
+
callTool(name: string, input: Record<string, unknown>): Promise<unknown>;
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class WebSocketMcpRpcClient implements McpRpcClient {
|
|
30
|
+
private ws?: WebSocket;
|
|
31
|
+
private readonly url: string;
|
|
32
|
+
private readonly timeoutMs: number;
|
|
33
|
+
private readonly reconnectAttempts: number;
|
|
34
|
+
private readonly reconnectDelayMs: number;
|
|
35
|
+
private idCounter = 1;
|
|
36
|
+
private readonly pending = new Map<
|
|
37
|
+
number,
|
|
38
|
+
{
|
|
39
|
+
resolve: (value: unknown) => void;
|
|
40
|
+
reject: (error: Error) => void;
|
|
41
|
+
timer: ReturnType<typeof setTimeout>;
|
|
42
|
+
}
|
|
43
|
+
>();
|
|
44
|
+
private opened = false;
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
url: string,
|
|
48
|
+
timeoutMs = 10_000,
|
|
49
|
+
reconnectAttempts = 3,
|
|
50
|
+
reconnectDelayMs = 500,
|
|
51
|
+
) {
|
|
52
|
+
this.url = url;
|
|
53
|
+
this.timeoutMs = timeoutMs;
|
|
54
|
+
this.reconnectAttempts = reconnectAttempts;
|
|
55
|
+
this.reconnectDelayMs = reconnectDelayMs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private attachHandlers(ws: WebSocket): void {
|
|
59
|
+
ws.on("open", () => {
|
|
60
|
+
this.opened = true;
|
|
61
|
+
});
|
|
62
|
+
ws.on("message", (data) => {
|
|
63
|
+
try {
|
|
64
|
+
const message = JSON.parse(String(data)) as {
|
|
65
|
+
id?: number;
|
|
66
|
+
result?: unknown;
|
|
67
|
+
error?: { message?: string };
|
|
68
|
+
};
|
|
69
|
+
if (typeof message.id !== "number") {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const pending = this.pending.get(message.id);
|
|
73
|
+
if (!pending) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
clearTimeout(pending.timer);
|
|
77
|
+
this.pending.delete(message.id);
|
|
78
|
+
if (message.error) {
|
|
79
|
+
pending.reject(new Error(message.error.message ?? "MCP RPC error"));
|
|
80
|
+
} else {
|
|
81
|
+
pending.resolve(message.result);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Ignore invalid messages.
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
ws.on("close", () => {
|
|
88
|
+
this.opened = false;
|
|
89
|
+
for (const [, pending] of this.pending) {
|
|
90
|
+
clearTimeout(pending.timer);
|
|
91
|
+
pending.reject(new Error("MCP websocket closed"));
|
|
92
|
+
}
|
|
93
|
+
this.pending.clear();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async openSocket(): Promise<void> {
|
|
98
|
+
const ws = new WebSocket(this.url);
|
|
99
|
+
this.ws = ws;
|
|
100
|
+
this.attachHandlers(ws);
|
|
101
|
+
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
102
|
+
const timeout = setTimeout(() => {
|
|
103
|
+
rejectPromise(new Error("MCP websocket open timeout"));
|
|
104
|
+
}, this.timeoutMs);
|
|
105
|
+
ws.once("open", () => {
|
|
106
|
+
clearTimeout(timeout);
|
|
107
|
+
resolvePromise();
|
|
108
|
+
});
|
|
109
|
+
ws.once("error", (error) => {
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
rejectPromise(error);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async waitUntilOpen(): Promise<void> {
|
|
117
|
+
if (this.opened && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
let lastError: unknown;
|
|
121
|
+
for (let attempt = 1; attempt <= this.reconnectAttempts; attempt += 1) {
|
|
122
|
+
try {
|
|
123
|
+
await this.openSocket();
|
|
124
|
+
return;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
lastError = error;
|
|
127
|
+
await new Promise((resolvePromise) =>
|
|
128
|
+
setTimeout(resolvePromise, this.reconnectDelayMs * attempt),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
throw lastError instanceof Error
|
|
133
|
+
? lastError
|
|
134
|
+
: new Error("Unable to connect to remote MCP websocket");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async request(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
|
138
|
+
await this.waitUntilOpen();
|
|
139
|
+
const id = this.idCounter++;
|
|
140
|
+
const payload = JSON.stringify({
|
|
141
|
+
jsonrpc: "2.0",
|
|
142
|
+
id,
|
|
143
|
+
method,
|
|
144
|
+
params: params ?? {},
|
|
145
|
+
});
|
|
146
|
+
const resultPromise = new Promise<unknown>((resolvePromise, rejectPromise) => {
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
this.pending.delete(id);
|
|
149
|
+
rejectPromise(new Error(`MCP websocket timeout for method ${method}`));
|
|
150
|
+
}, this.timeoutMs);
|
|
151
|
+
this.pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timer });
|
|
152
|
+
});
|
|
153
|
+
const socket = this.ws;
|
|
154
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
155
|
+
throw new Error("MCP websocket is not connected");
|
|
156
|
+
}
|
|
157
|
+
socket.send(payload);
|
|
158
|
+
return await resultPromise;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async listTools(): Promise<McpToolDescriptor[]> {
|
|
162
|
+
const result = (await this.request("tools/list")) as { tools?: McpToolDescriptor[] } | unknown;
|
|
163
|
+
const value = (result as { tools?: McpToolDescriptor[] })?.tools;
|
|
164
|
+
return Array.isArray(value) ? value : [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async callTool(name: string, input: Record<string, unknown>): Promise<unknown> {
|
|
168
|
+
const result = (await this.request("tools/call", { name, arguments: input })) as {
|
|
169
|
+
content?: unknown;
|
|
170
|
+
result?: unknown;
|
|
171
|
+
};
|
|
172
|
+
return result?.result ?? result?.content ?? result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async close(): Promise<void> {
|
|
176
|
+
this.ws?.close();
|
|
177
|
+
this.ws = undefined;
|
|
178
|
+
this.opened = false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export class LocalMcpBridge {
|
|
183
|
+
private readonly remoteServers: RemoteMcpServerConfig[];
|
|
184
|
+
private readonly rpcClients = new Map<string, McpRpcClient>();
|
|
185
|
+
|
|
186
|
+
constructor(config: McpConfig | undefined) {
|
|
187
|
+
this.remoteServers = (config?.mcp ?? []).filter((entry): entry is RemoteMcpServerConfig =>
|
|
188
|
+
typeof entry.url === "string",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async loadTools(): Promise<ToolDefinition[]> {
|
|
193
|
+
const tools: ToolDefinition[] = [];
|
|
194
|
+
for (const remoteServer of this.remoteServers) {
|
|
195
|
+
const name = remoteServer.name ?? remoteServer.url;
|
|
196
|
+
const client = this.rpcClients.get(name);
|
|
197
|
+
if (!client) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const discovered = await client.listTools();
|
|
202
|
+
tools.push(...this.toToolDefinitions(name, discovered, client));
|
|
203
|
+
} catch {
|
|
204
|
+
// Ignore server discovery failures and continue boot.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return tools;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async startLocalServers(): Promise<void> {
|
|
211
|
+
for (const server of this.remoteServers) {
|
|
212
|
+
const name = server.name ?? server.url;
|
|
213
|
+
this.rpcClients.set(
|
|
214
|
+
name,
|
|
215
|
+
new WebSocketMcpRpcClient(
|
|
216
|
+
server.url,
|
|
217
|
+
server.timeoutMs ?? 10_000,
|
|
218
|
+
server.reconnectAttempts ?? 3,
|
|
219
|
+
server.reconnectDelayMs ?? 500,
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async stopLocalServers(): Promise<void> {
|
|
226
|
+
for (const [, client] of this.rpcClients) {
|
|
227
|
+
await client.close();
|
|
228
|
+
}
|
|
229
|
+
this.rpcClients.clear();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
listServers(): RemoteMcpServerConfig[] {
|
|
233
|
+
return [...this.remoteServers];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
listRemoteServers(): RemoteMcpServerConfig[] {
|
|
237
|
+
return this.remoteServers;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async checkRemoteConnectivity(): Promise<
|
|
241
|
+
Array<{ url: string; ok: boolean; error?: string }>
|
|
242
|
+
> {
|
|
243
|
+
const checks: Array<{ url: string; ok: boolean; error?: string }> = [];
|
|
244
|
+
for (const remote of this.remoteServers) {
|
|
245
|
+
try {
|
|
246
|
+
if (remote.url.startsWith("http://") || remote.url.startsWith("https://")) {
|
|
247
|
+
const response = await fetch(remote.url, { method: "HEAD" });
|
|
248
|
+
checks.push({ url: remote.url, ok: response.ok });
|
|
249
|
+
} else {
|
|
250
|
+
checks.push({ url: remote.url, ok: true });
|
|
251
|
+
}
|
|
252
|
+
} catch (error) {
|
|
253
|
+
checks.push({
|
|
254
|
+
url: remote.url,
|
|
255
|
+
ok: false,
|
|
256
|
+
error: error instanceof Error ? error.message : "Unknown connectivity error",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return checks;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
toSerializableConfig(): McpConfig {
|
|
264
|
+
return { mcp: [...this.remoteServers] };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
getLocalServers(): never[] {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private toToolDefinitions(
|
|
272
|
+
serverName: string,
|
|
273
|
+
tools: McpToolDescriptor[],
|
|
274
|
+
client: McpRpcClient,
|
|
275
|
+
): ToolDefinition[] {
|
|
276
|
+
return tools.map((tool) => ({
|
|
277
|
+
name: `${serverName}:${tool.name}`,
|
|
278
|
+
description: tool.description ?? `MCP tool ${tool.name} from ${serverName}`,
|
|
279
|
+
inputSchema:
|
|
280
|
+
(tool.inputSchema as ToolDefinition["inputSchema"]) ?? {
|
|
281
|
+
type: "object",
|
|
282
|
+
properties: {},
|
|
283
|
+
},
|
|
284
|
+
handler: async (input) => await client.callTool(tool.name, input),
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
}
|