@poncho-ai/harness 0.24.0 → 0.25.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 +5 -5
- package/.turbo/turbo-lint.log +6 -0
- package/.turbo/turbo-test.log +135 -0
- package/CHANGELOG.md +6 -0
- package/dist/index.d.ts +17 -1
- package/dist/index.js +175 -78
- package/package.json +1 -1
- package/src/harness.ts +122 -38
- package/src/memory.ts +63 -46
- package/test/harness.test.ts +129 -2
- package/test/memory.test.ts +100 -15
package/src/harness.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
2
4
|
import type {
|
|
3
5
|
AgentEvent,
|
|
4
6
|
ContentPart,
|
|
@@ -13,7 +15,7 @@ import type {
|
|
|
13
15
|
import { getTextContent } from "@poncho-ai/sdk";
|
|
14
16
|
import type { UploadStore } from "./upload-store.js";
|
|
15
17
|
import { PONCHO_UPLOAD_SCHEME, deriveUploadKey } from "./upload-store.js";
|
|
16
|
-
import { parseAgentFile, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
|
|
18
|
+
import { parseAgentFile, parseAgentMarkdown, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
|
|
17
19
|
import { loadPonchoConfig, resolveMemoryConfig, type PonchoConfig, type ToolAccess, type BuiltInToolToggles } from "./config.js";
|
|
18
20
|
import { createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createWriteTool, ponchoDocsTool } from "./default-tools.js";
|
|
19
21
|
import {
|
|
@@ -563,6 +565,7 @@ export class AgentHarness {
|
|
|
563
565
|
};
|
|
564
566
|
|
|
565
567
|
private parsedAgent?: ParsedAgent;
|
|
568
|
+
private agentFileFingerprint = "";
|
|
566
569
|
private mcpBridge?: LocalMcpBridge;
|
|
567
570
|
private subagentManager?: SubagentManager;
|
|
568
571
|
|
|
@@ -696,20 +699,17 @@ export class AgentHarness {
|
|
|
696
699
|
}
|
|
697
700
|
|
|
698
701
|
private getRequestedMcpPatterns(): string[] {
|
|
699
|
-
const
|
|
702
|
+
const patterns = new Set<string>(this.getAgentMcpIntent());
|
|
700
703
|
for (const skillName of this.activeSkillNames) {
|
|
701
704
|
const skill = this.loadedSkills.find((entry) => entry.name === skillName);
|
|
702
705
|
if (!skill) {
|
|
703
706
|
continue;
|
|
704
707
|
}
|
|
705
708
|
for (const pattern of skill.allowedTools.mcp) {
|
|
706
|
-
|
|
709
|
+
patterns.add(pattern);
|
|
707
710
|
}
|
|
708
711
|
}
|
|
709
|
-
|
|
710
|
-
return [...skillPatterns];
|
|
711
|
-
}
|
|
712
|
-
return this.getAgentMcpIntent();
|
|
712
|
+
return [...patterns];
|
|
713
713
|
}
|
|
714
714
|
|
|
715
715
|
private getRequestedScriptPatterns(): string[] {
|
|
@@ -727,20 +727,17 @@ export class AgentHarness {
|
|
|
727
727
|
}
|
|
728
728
|
|
|
729
729
|
private getRequestedMcpApprovalPatterns(): string[] {
|
|
730
|
-
const
|
|
730
|
+
const patterns = new Set<string>(this.getAgentMcpApprovalPatterns());
|
|
731
731
|
for (const skillName of this.activeSkillNames) {
|
|
732
732
|
const skill = this.loadedSkills.find((entry) => entry.name === skillName);
|
|
733
733
|
if (!skill) {
|
|
734
734
|
continue;
|
|
735
735
|
}
|
|
736
736
|
for (const pattern of skill.approvalRequired.mcp) {
|
|
737
|
-
|
|
737
|
+
patterns.add(pattern);
|
|
738
738
|
}
|
|
739
739
|
}
|
|
740
|
-
|
|
741
|
-
return [...skillPatterns];
|
|
742
|
-
}
|
|
743
|
-
return this.getAgentMcpApprovalPatterns();
|
|
740
|
+
return [...patterns];
|
|
744
741
|
}
|
|
745
742
|
|
|
746
743
|
private getRequestedScriptApprovalPatterns(): string[] {
|
|
@@ -891,13 +888,59 @@ export class AgentHarness {
|
|
|
891
888
|
|
|
892
889
|
private static readonly SKILL_REFRESH_DEBOUNCE_MS = 3000;
|
|
893
890
|
|
|
894
|
-
|
|
891
|
+
/**
|
|
892
|
+
* Re-read AGENT.md and update the parsed agent when the file has changed
|
|
893
|
+
* on disk. Returns `true` when the agent was actually re-parsed.
|
|
894
|
+
*
|
|
895
|
+
* Preserves the agent identity (id) across reloads so conversation
|
|
896
|
+
* continuity isn't broken.
|
|
897
|
+
*/
|
|
898
|
+
private async refreshAgentIfChanged(): Promise<boolean> {
|
|
895
899
|
if (this.environment !== "development") {
|
|
896
|
-
return;
|
|
900
|
+
return false;
|
|
897
901
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
902
|
+
try {
|
|
903
|
+
const agentFilePath = resolve(this.workingDir, "AGENT.md");
|
|
904
|
+
const rawContent = await readFile(agentFilePath, "utf8");
|
|
905
|
+
if (rawContent === this.agentFileFingerprint) {
|
|
906
|
+
return false;
|
|
907
|
+
}
|
|
908
|
+
const parsed = parseAgentMarkdown(rawContent);
|
|
909
|
+
// Preserve the resolved agent identity so existing conversations
|
|
910
|
+
// keep working after an AGENT.md edit.
|
|
911
|
+
if (!parsed.frontmatter.id && this.parsedAgent?.frontmatter.id) {
|
|
912
|
+
parsed.frontmatter.id = this.parsedAgent.frontmatter.id;
|
|
913
|
+
}
|
|
914
|
+
this.parsedAgent = parsed;
|
|
915
|
+
this.agentFileFingerprint = rawContent;
|
|
916
|
+
return true;
|
|
917
|
+
} catch (error) {
|
|
918
|
+
console.warn(
|
|
919
|
+
`[poncho][agent] Failed to refresh AGENT.md in development mode: ${
|
|
920
|
+
error instanceof Error ? error.message : String(error)
|
|
921
|
+
}`,
|
|
922
|
+
);
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Re-scan skill directories and update metadata, tools, and context window
|
|
929
|
+
* when skills have changed on disk. Returns `true` when the skill set was
|
|
930
|
+
* actually updated.
|
|
931
|
+
*
|
|
932
|
+
* @param force - bypass the time-based debounce (used for mid-run refreshes
|
|
933
|
+
* after the agent may have written new skill files).
|
|
934
|
+
*/
|
|
935
|
+
private async refreshSkillsIfChanged(force = false): Promise<boolean> {
|
|
936
|
+
if (this.environment !== "development") {
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
if (!force) {
|
|
940
|
+
const elapsed = Date.now() - this.lastSkillRefreshAt;
|
|
941
|
+
if (this.lastSkillRefreshAt > 0 && elapsed < AgentHarness.SKILL_REFRESH_DEBOUNCE_MS) {
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
901
944
|
}
|
|
902
945
|
this.lastSkillRefreshAt = Date.now();
|
|
903
946
|
try {
|
|
@@ -907,27 +950,44 @@ export class AgentHarness {
|
|
|
907
950
|
);
|
|
908
951
|
const nextFingerprint = this.buildSkillFingerprint(latestSkills);
|
|
909
952
|
if (nextFingerprint === this.skillFingerprint) {
|
|
910
|
-
return;
|
|
953
|
+
return false;
|
|
911
954
|
}
|
|
912
955
|
this.loadedSkills = latestSkills;
|
|
913
956
|
this.skillContextWindow = buildSkillContextWindow(latestSkills);
|
|
914
957
|
this.skillFingerprint = nextFingerprint;
|
|
915
958
|
this.registerSkillTools(latestSkills);
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
|
|
959
|
+
// Prune active skills that no longer exist in the updated metadata,
|
|
960
|
+
// but preserve ones that were merely updated (same name). This keeps
|
|
961
|
+
// MCP tools from active skills registered when their allowed-tools
|
|
962
|
+
// list changes, instead of forcing the agent to re-activate.
|
|
963
|
+
const latestSkillNames = new Set(latestSkills.map(s => s.name));
|
|
964
|
+
for (const name of this.activeSkillNames) {
|
|
965
|
+
if (!latestSkillNames.has(name)) {
|
|
966
|
+
this.activeSkillNames.delete(name);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
// Re-discover MCP server catalogs so newly advertised tools are visible,
|
|
970
|
+
// then refresh the registered tool set with updated skill patterns.
|
|
971
|
+
if (this.mcpBridge) {
|
|
972
|
+
await this.mcpBridge.discoverTools();
|
|
973
|
+
}
|
|
919
974
|
await this.refreshMcpTools("skills:changed");
|
|
975
|
+
return true;
|
|
920
976
|
} catch (error) {
|
|
921
977
|
console.warn(
|
|
922
978
|
`[poncho][skills] Failed to refresh skills in development mode: ${
|
|
923
979
|
error instanceof Error ? error.message : String(error)
|
|
924
980
|
}`,
|
|
925
981
|
);
|
|
982
|
+
return false;
|
|
926
983
|
}
|
|
927
984
|
}
|
|
928
985
|
|
|
929
986
|
async initialize(): Promise<void> {
|
|
930
|
-
|
|
987
|
+
const agentFilePath = resolve(this.workingDir, "AGENT.md");
|
|
988
|
+
const agentRawContent = await readFile(agentFilePath, "utf8");
|
|
989
|
+
this.parsedAgent = parseAgentMarkdown(agentRawContent);
|
|
990
|
+
this.agentFileFingerprint = agentRawContent;
|
|
931
991
|
const identity = await ensureAgentIdentity(this.workingDir);
|
|
932
992
|
if (!this.parsedAgent.frontmatter.id) {
|
|
933
993
|
this.parsedAgent.frontmatter.id = identity.id;
|
|
@@ -1286,10 +1346,11 @@ export class AgentHarness {
|
|
|
1286
1346
|
if (!this.parsedAgent) {
|
|
1287
1347
|
await this.initialize();
|
|
1288
1348
|
}
|
|
1289
|
-
// Start memory fetch early so it overlaps with
|
|
1349
|
+
// Start memory fetch early so it overlaps with refresh I/O
|
|
1290
1350
|
const memoryPromise = this.memoryStore
|
|
1291
1351
|
? this.memoryStore.getMainMemory()
|
|
1292
1352
|
: undefined;
|
|
1353
|
+
await this.refreshAgentIfChanged();
|
|
1293
1354
|
await this.refreshSkillsIfChanged();
|
|
1294
1355
|
|
|
1295
1356
|
// Track which conversation/owner this run belongs to so browser & subagent tools resolve correctly
|
|
@@ -1299,7 +1360,7 @@ export class AgentHarness {
|
|
|
1299
1360
|
this._currentRunOwnerId = ownerParam;
|
|
1300
1361
|
}
|
|
1301
1362
|
|
|
1302
|
-
|
|
1363
|
+
let agent = this.parsedAgent as ParsedAgent;
|
|
1303
1364
|
const runId = `run_${randomUUID()}`;
|
|
1304
1365
|
const start = now();
|
|
1305
1366
|
const maxSteps = agent.frontmatter.limits?.maxSteps ?? 50;
|
|
@@ -1315,15 +1376,16 @@ export class AgentHarness {
|
|
|
1315
1376
|
const inputMessageCount = messages.length;
|
|
1316
1377
|
const events: AgentEvent[] = [];
|
|
1317
1378
|
|
|
1318
|
-
const
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1379
|
+
const renderCurrentAgentPrompt = (): string =>
|
|
1380
|
+
renderAgentPrompt(this.parsedAgent!, {
|
|
1381
|
+
parameters: input.parameters,
|
|
1382
|
+
runtime: {
|
|
1383
|
+
runId,
|
|
1384
|
+
agentId: this.parsedAgent!.frontmatter.id ?? this.parsedAgent!.frontmatter.name,
|
|
1385
|
+
environment: this.environment,
|
|
1386
|
+
workingDir: this.workingDir,
|
|
1387
|
+
},
|
|
1388
|
+
});
|
|
1327
1389
|
const developmentContext =
|
|
1328
1390
|
this.environment === "development" ? `\n\n${DEVELOPMENT_MODE_CONTEXT}` : "";
|
|
1329
1391
|
const browserContext = this._browserSession
|
|
@@ -1346,9 +1408,6 @@ Browser sessions (cookies, localStorage, login state) are automatically saved an
|
|
|
1346
1408
|
### Tabs and resources
|
|
1347
1409
|
Each conversation gets its own browser tab sharing a single browser instance. Call \`browser_close\` when done to free the tab. If you don't close it, the tab stays open and the user can continue interacting with it.`
|
|
1348
1410
|
: "";
|
|
1349
|
-
const promptWithSkills = this.skillContextWindow
|
|
1350
|
-
? `${systemPrompt}${developmentContext}\n\n${this.skillContextWindow}${browserContext}`
|
|
1351
|
-
: `${systemPrompt}${developmentContext}${browserContext}`;
|
|
1352
1411
|
const mainMemory = await memoryPromise;
|
|
1353
1412
|
const boundedMainMemory =
|
|
1354
1413
|
mainMemory && mainMemory.content.length > 4000
|
|
@@ -1361,7 +1420,13 @@ Each conversation gets its own browser tab sharing a single browser instance. Ca
|
|
|
1361
1420
|
|
|
1362
1421
|
${boundedMainMemory.trim()}`
|
|
1363
1422
|
: "";
|
|
1364
|
-
|
|
1423
|
+
|
|
1424
|
+
const buildSystemPrompt = (): string => {
|
|
1425
|
+
const agentPrompt = renderCurrentAgentPrompt();
|
|
1426
|
+
const promptWithSkills = this.skillContextWindow
|
|
1427
|
+
? `${agentPrompt}${developmentContext}\n\n${this.skillContextWindow}${browserContext}`
|
|
1428
|
+
: `${agentPrompt}${developmentContext}${browserContext}`;
|
|
1429
|
+
return `${promptWithSkills}${memoryContext}
|
|
1365
1430
|
|
|
1366
1431
|
## Execution Integrity
|
|
1367
1432
|
|
|
@@ -1369,6 +1434,9 @@ ${boundedMainMemory.trim()}`
|
|
|
1369
1434
|
- Do not fabricate "Tool Used" or "Tool Result" logs as plain text.
|
|
1370
1435
|
- Never output faux execution transcripts, markdown tool logs, or "Tool Used/Result" sections.
|
|
1371
1436
|
- If no suitable tool is available, explicitly say that and ask for guidance.`;
|
|
1437
|
+
};
|
|
1438
|
+
let integrityPrompt = buildSystemPrompt();
|
|
1439
|
+
let lastPromptFingerprint = `${this.agentFileFingerprint}\n${this.skillFingerprint}`;
|
|
1372
1440
|
|
|
1373
1441
|
const pushEvent = (event: AgentEvent): AgentEvent => {
|
|
1374
1442
|
events.push(event);
|
|
@@ -2260,6 +2328,22 @@ ${boundedMainMemory.trim()}`
|
|
|
2260
2328
|
metadata: toolMsgMeta as Message["metadata"],
|
|
2261
2329
|
});
|
|
2262
2330
|
|
|
2331
|
+
// In development, re-read AGENT.md and re-scan skills after tool
|
|
2332
|
+
// execution so changes are available on the next step without
|
|
2333
|
+
// requiring a server restart.
|
|
2334
|
+
if (this.environment === "development") {
|
|
2335
|
+
const agentChanged = await this.refreshAgentIfChanged();
|
|
2336
|
+
const skillsChanged = await this.refreshSkillsIfChanged(true);
|
|
2337
|
+
if (agentChanged || skillsChanged) {
|
|
2338
|
+
agent = this.parsedAgent as ParsedAgent;
|
|
2339
|
+
const currentFingerprint = `${this.agentFileFingerprint}\n${this.skillFingerprint}`;
|
|
2340
|
+
if (currentFingerprint !== lastPromptFingerprint) {
|
|
2341
|
+
integrityPrompt = buildSystemPrompt();
|
|
2342
|
+
lastPromptFingerprint = currentFingerprint;
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2263
2347
|
yield pushEvent({
|
|
2264
2348
|
type: "step:completed",
|
|
2265
2349
|
step,
|
package/src/memory.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface MemoryConfig {
|
|
|
27
27
|
|
|
28
28
|
export interface MemoryStore {
|
|
29
29
|
getMainMemory(): Promise<MainMemory>;
|
|
30
|
-
updateMainMemory(input: { content: string
|
|
30
|
+
updateMainMemory(input: { content: string }): Promise<MainMemory>;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
type MainMemoryPayload = {
|
|
@@ -89,19 +89,10 @@ class InMemoryMemoryStore implements MemoryStore {
|
|
|
89
89
|
return this.mainMemory;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
async updateMainMemory(input: {
|
|
93
|
-
content: string;
|
|
94
|
-
mode?: "replace" | "append";
|
|
95
|
-
}): Promise<MainMemory> {
|
|
96
|
-
const now = Date.now();
|
|
97
|
-
const existing = await this.getMainMemory();
|
|
98
|
-
const nextContent =
|
|
99
|
-
input.mode === "append" && existing.content
|
|
100
|
-
? `${existing.content}\n\n${input.content}`.trim()
|
|
101
|
-
: input.content;
|
|
92
|
+
async updateMainMemory(input: { content: string }): Promise<MainMemory> {
|
|
102
93
|
this.mainMemory = {
|
|
103
|
-
content:
|
|
104
|
-
updatedAt: now,
|
|
94
|
+
content: input.content.trim(),
|
|
95
|
+
updatedAt: Date.now(),
|
|
105
96
|
};
|
|
106
97
|
return this.mainMemory;
|
|
107
98
|
}
|
|
@@ -166,18 +157,10 @@ class FileMainMemoryStore implements MemoryStore {
|
|
|
166
157
|
return this.mainMemory;
|
|
167
158
|
}
|
|
168
159
|
|
|
169
|
-
async updateMainMemory(input: {
|
|
170
|
-
content: string;
|
|
171
|
-
mode?: "replace" | "append";
|
|
172
|
-
}): Promise<MainMemory> {
|
|
160
|
+
async updateMainMemory(input: { content: string }): Promise<MainMemory> {
|
|
173
161
|
await this.ensureLoaded();
|
|
174
|
-
const existing = await this.getMainMemory();
|
|
175
|
-
const nextContent =
|
|
176
|
-
input.mode === "append" && existing.content
|
|
177
|
-
? `${existing.content}\n\n${input.content}`.trim()
|
|
178
|
-
: input.content;
|
|
179
162
|
this.mainMemory = {
|
|
180
|
-
content:
|
|
163
|
+
content: input.content.trim(),
|
|
181
164
|
updatedAt: Date.now(),
|
|
182
165
|
};
|
|
183
166
|
await this.persist();
|
|
@@ -225,7 +208,6 @@ abstract class KeyValueMainMemoryStoreBase implements MemoryStore {
|
|
|
225
208
|
} catch {
|
|
226
209
|
await this.memoryFallback.updateMainMemory({
|
|
227
210
|
content: payload.main.content,
|
|
228
|
-
mode: "replace",
|
|
229
211
|
});
|
|
230
212
|
}
|
|
231
213
|
}
|
|
@@ -235,18 +217,11 @@ abstract class KeyValueMainMemoryStoreBase implements MemoryStore {
|
|
|
235
217
|
return payload.main;
|
|
236
218
|
}
|
|
237
219
|
|
|
238
|
-
async updateMainMemory(input: {
|
|
239
|
-
content: string;
|
|
240
|
-
mode?: "replace" | "append";
|
|
241
|
-
}): Promise<MainMemory> {
|
|
220
|
+
async updateMainMemory(input: { content: string }): Promise<MainMemory> {
|
|
242
221
|
const key = this.key();
|
|
243
222
|
const payload = await this.readPayload(key);
|
|
244
|
-
const nextContent =
|
|
245
|
-
input.mode === "append" && payload.main.content
|
|
246
|
-
? `${payload.main.content}\n\n${input.content}`.trim()
|
|
247
|
-
: input.content;
|
|
248
223
|
payload.main = {
|
|
249
|
-
content:
|
|
224
|
+
content: input.content.trim(),
|
|
250
225
|
updatedAt: Date.now(),
|
|
251
226
|
};
|
|
252
227
|
await this.writePayload(key, payload);
|
|
@@ -590,20 +565,17 @@ export const createMemoryTools = (
|
|
|
590
565
|
},
|
|
591
566
|
}),
|
|
592
567
|
defineTool({
|
|
593
|
-
name: "
|
|
568
|
+
name: "memory_main_write",
|
|
594
569
|
description:
|
|
595
|
-
"
|
|
570
|
+
"Overwrite the entire persistent main memory document. " +
|
|
571
|
+
"Use for initial writes or full rewrites. " +
|
|
572
|
+
"Prefer memory_main_edit for targeted changes to existing memory.",
|
|
596
573
|
inputSchema: {
|
|
597
574
|
type: "object",
|
|
598
575
|
properties: {
|
|
599
|
-
mode: {
|
|
600
|
-
type: "string",
|
|
601
|
-
enum: ["replace", "append"],
|
|
602
|
-
description: "replace overwrites memory; append adds content to the end",
|
|
603
|
-
},
|
|
604
576
|
content: {
|
|
605
577
|
type: "string",
|
|
606
|
-
description: "The memory content to write",
|
|
578
|
+
description: "The full memory content to write",
|
|
607
579
|
},
|
|
608
580
|
},
|
|
609
581
|
required: ["content"],
|
|
@@ -614,11 +586,56 @@ export const createMemoryTools = (
|
|
|
614
586
|
if (!content) {
|
|
615
587
|
throw new Error("content is required");
|
|
616
588
|
}
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
589
|
+
const memory = await store.updateMainMemory({ content });
|
|
590
|
+
return { ok: true, memory };
|
|
591
|
+
},
|
|
592
|
+
}),
|
|
593
|
+
defineTool({
|
|
594
|
+
name: "memory_main_edit",
|
|
595
|
+
description:
|
|
596
|
+
"Edit persistent main memory by replacing an exact string match with new content. " +
|
|
597
|
+
"The old_str must match exactly one location in memory. " +
|
|
598
|
+
"Use an empty new_str to delete matched content. " +
|
|
599
|
+
"Proactively evaluate every turn whether memory should be updated.",
|
|
600
|
+
inputSchema: {
|
|
601
|
+
type: "object",
|
|
602
|
+
properties: {
|
|
603
|
+
old_str: {
|
|
604
|
+
type: "string",
|
|
605
|
+
description:
|
|
606
|
+
"The exact text to find and replace (must be unique in memory). " +
|
|
607
|
+
"Include surrounding context if needed to ensure uniqueness.",
|
|
608
|
+
},
|
|
609
|
+
new_str: {
|
|
610
|
+
type: "string",
|
|
611
|
+
description: "The replacement text (use empty string to delete the matched content)",
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
required: ["old_str", "new_str"],
|
|
615
|
+
additionalProperties: false,
|
|
616
|
+
},
|
|
617
|
+
handler: async (input) => {
|
|
618
|
+
const oldStr = typeof input.old_str === "string" ? input.old_str : "";
|
|
619
|
+
const newStr = typeof input.new_str === "string" ? input.new_str : "";
|
|
620
|
+
if (!oldStr) {
|
|
621
|
+
throw new Error("old_str must not be empty.");
|
|
622
|
+
}
|
|
623
|
+
const current = await store.getMainMemory();
|
|
624
|
+
const content = current.content;
|
|
625
|
+
const first = content.indexOf(oldStr);
|
|
626
|
+
if (first === -1) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
"old_str not found in memory. Make sure it matches exactly, including whitespace and line breaks.",
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
const last = content.lastIndexOf(oldStr);
|
|
632
|
+
if (first !== last) {
|
|
633
|
+
throw new Error(
|
|
634
|
+
"old_str appears multiple times in memory. Please provide more context to ensure a unique match.",
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
const newContent = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
|
|
638
|
+
const memory = await store.updateMainMemory({ content: newContent });
|
|
622
639
|
return { ok: true, memory };
|
|
623
640
|
},
|
|
624
641
|
}),
|
package/test/harness.test.ts
CHANGED
|
@@ -367,7 +367,7 @@ description: Beta skill
|
|
|
367
367
|
});
|
|
368
368
|
});
|
|
369
369
|
|
|
370
|
-
it("
|
|
370
|
+
it("preserves active skills when skill metadata changes in development mode", async () => {
|
|
371
371
|
const dir = await mkdtemp(join(tmpdir(), "poncho-harness-skill-refresh-clear-active-"));
|
|
372
372
|
await writeFile(
|
|
373
373
|
join(dir, "AGENT.md"),
|
|
@@ -405,6 +405,7 @@ description: Alpha skill
|
|
|
405
405
|
await activate!.handler({ name: "alpha" }, {} as any);
|
|
406
406
|
expect(await listActive!.handler({}, {} as any)).toEqual({ activeSkills: ["alpha"] });
|
|
407
407
|
|
|
408
|
+
// Update the skill metadata — the skill keeps the same name so it stays active
|
|
408
409
|
await writeFile(
|
|
409
410
|
join(dir, "skills", "alpha", "SKILL.md"),
|
|
410
411
|
`---
|
|
@@ -417,7 +418,7 @@ description: Alpha skill updated
|
|
|
417
418
|
"utf8",
|
|
418
419
|
);
|
|
419
420
|
await (harness as any).refreshSkillsIfChanged();
|
|
420
|
-
expect(await listActive!.handler({}, {} as any)).toEqual({ activeSkills: [] });
|
|
421
|
+
expect(await listActive!.handler({}, {} as any)).toEqual({ activeSkills: ["alpha"] });
|
|
421
422
|
});
|
|
422
423
|
|
|
423
424
|
it("lists skill scripts through list_skill_scripts", async () => {
|
|
@@ -910,6 +911,132 @@ allowed-tools:
|
|
|
910
911
|
await new Promise<void>((resolveClose) => mcpServer.close(() => resolveClose()));
|
|
911
912
|
});
|
|
912
913
|
|
|
914
|
+
it("agent-level MCP tools persist when a skill is activated (additive)", async () => {
|
|
915
|
+
process.env.LINEAR_TOKEN = "token-123";
|
|
916
|
+
const mcpServer = createServer(async (req, res) => {
|
|
917
|
+
if (req.method === "DELETE") {
|
|
918
|
+
res.statusCode = 200;
|
|
919
|
+
res.end();
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const chunks: Buffer[] = [];
|
|
923
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
924
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
925
|
+
const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
|
|
926
|
+
if (payload.method === "initialize") {
|
|
927
|
+
res.setHeader("Content-Type", "application/json");
|
|
928
|
+
res.setHeader("Mcp-Session-Id", "sess");
|
|
929
|
+
res.end(
|
|
930
|
+
JSON.stringify({
|
|
931
|
+
jsonrpc: "2.0",
|
|
932
|
+
id: payload.id,
|
|
933
|
+
result: {
|
|
934
|
+
protocolVersion: "2025-03-26",
|
|
935
|
+
capabilities: { tools: { listChanged: true } },
|
|
936
|
+
serverInfo: { name: "remote", version: "1.0.0" },
|
|
937
|
+
},
|
|
938
|
+
}),
|
|
939
|
+
);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (payload.method === "notifications/initialized") {
|
|
943
|
+
res.statusCode = 202;
|
|
944
|
+
res.end();
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (payload.method === "tools/list") {
|
|
948
|
+
res.setHeader("Content-Type", "application/json");
|
|
949
|
+
res.end(
|
|
950
|
+
JSON.stringify({
|
|
951
|
+
jsonrpc: "2.0",
|
|
952
|
+
id: payload.id,
|
|
953
|
+
result: {
|
|
954
|
+
tools: [
|
|
955
|
+
{ name: "a", inputSchema: { type: "object", properties: {} } },
|
|
956
|
+
{ name: "b", inputSchema: { type: "object", properties: {} } },
|
|
957
|
+
],
|
|
958
|
+
},
|
|
959
|
+
}),
|
|
960
|
+
);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (payload.method === "tools/call") {
|
|
964
|
+
res.setHeader("Content-Type", "application/json");
|
|
965
|
+
res.end(
|
|
966
|
+
JSON.stringify({
|
|
967
|
+
jsonrpc: "2.0",
|
|
968
|
+
id: payload.id,
|
|
969
|
+
result: { result: { ok: true } },
|
|
970
|
+
}),
|
|
971
|
+
);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
res.statusCode = 404;
|
|
975
|
+
res.end();
|
|
976
|
+
});
|
|
977
|
+
await new Promise<void>((resolveOpen) => mcpServer.listen(0, () => resolveOpen()));
|
|
978
|
+
const address = mcpServer.address();
|
|
979
|
+
if (!address || typeof address === "string") throw new Error("Unexpected address");
|
|
980
|
+
const dir = await mkdtemp(join(tmpdir(), "poncho-harness-additive-mcp-"));
|
|
981
|
+
await writeFile(
|
|
982
|
+
join(dir, "AGENT.md"),
|
|
983
|
+
`---
|
|
984
|
+
name: additive-agent
|
|
985
|
+
model:
|
|
986
|
+
provider: anthropic
|
|
987
|
+
name: claude-opus-4-5
|
|
988
|
+
allowed-tools:
|
|
989
|
+
- mcp:remote/a
|
|
990
|
+
---
|
|
991
|
+
|
|
992
|
+
# Additive Agent
|
|
993
|
+
`,
|
|
994
|
+
"utf8",
|
|
995
|
+
);
|
|
996
|
+
await writeFile(
|
|
997
|
+
join(dir, "poncho.config.js"),
|
|
998
|
+
`export default {
|
|
999
|
+
mcp: [
|
|
1000
|
+
{
|
|
1001
|
+
name: "remote",
|
|
1002
|
+
url: "http://127.0.0.1:${address.port}/mcp",
|
|
1003
|
+
auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" }
|
|
1004
|
+
}
|
|
1005
|
+
]
|
|
1006
|
+
};
|
|
1007
|
+
`,
|
|
1008
|
+
"utf8",
|
|
1009
|
+
);
|
|
1010
|
+
await mkdir(join(dir, "skills", "skill-b"), { recursive: true });
|
|
1011
|
+
await writeFile(
|
|
1012
|
+
join(dir, "skills", "skill-b", "SKILL.md"),
|
|
1013
|
+
`---
|
|
1014
|
+
name: skill-b
|
|
1015
|
+
description: B
|
|
1016
|
+
allowed-tools:
|
|
1017
|
+
- mcp:remote/b
|
|
1018
|
+
---
|
|
1019
|
+
# B
|
|
1020
|
+
`,
|
|
1021
|
+
"utf8",
|
|
1022
|
+
);
|
|
1023
|
+
const harness = new AgentHarness({ workingDir: dir });
|
|
1024
|
+
await harness.initialize();
|
|
1025
|
+
const toolNames = () => harness.listTools().map((t) => t.name);
|
|
1026
|
+
expect(toolNames()).toContain("remote/a");
|
|
1027
|
+
expect(toolNames()).not.toContain("remote/b");
|
|
1028
|
+
const activate = harness.listTools().find((t) => t.name === "activate_skill")!;
|
|
1029
|
+
const deactivate = harness.listTools().find((t) => t.name === "deactivate_skill")!;
|
|
1030
|
+
await activate.handler({ name: "skill-b" }, {} as any);
|
|
1031
|
+
expect(toolNames()).toContain("remote/a");
|
|
1032
|
+
expect(toolNames()).toContain("remote/b");
|
|
1033
|
+
await deactivate.handler({ name: "skill-b" }, {} as any);
|
|
1034
|
+
expect(toolNames()).toContain("remote/a");
|
|
1035
|
+
expect(toolNames()).not.toContain("remote/b");
|
|
1036
|
+
await harness.shutdown();
|
|
1037
|
+
await new Promise<void>((resolveClose) => mcpServer.close(() => resolveClose()));
|
|
1038
|
+
});
|
|
1039
|
+
|
|
913
1040
|
it("supports flat tool access config format", async () => {
|
|
914
1041
|
const dir = await mkdtemp(join(tmpdir(), "poncho-harness-flat-tool-access-"));
|
|
915
1042
|
await writeFile(
|