@tyvm/knowhow 0.0.138 → 0.0.141
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/autodoc/chat-guide.md +1 -0
- package/package.json +5 -2
- package/scripts/build-for-node.sh +4 -4
- package/src/agents/base/base.ts +254 -29
- package/src/agents/patcher/patcher.ts +1 -1
- package/src/agents/researcher/researcher.ts +2 -2
- package/src/agents/tools/agentCall.ts +6 -0
- package/src/agents/tools/connectAgent.ts +353 -0
- package/src/agents/tools/index.ts +6 -0
- package/src/agents/tools/list.ts +48 -0
- package/src/agents/tools/observe.ts +245 -0
- package/src/agents/tools/replyToParent.ts +122 -0
- package/src/agents/tools/runGenerate.ts +209 -0
- package/src/agents/tools/sendAgentMessage.ts +138 -0
- package/src/agents/tools/startAgentTask.ts +92 -5
- package/src/agents/tools/waitForAgentCompleted.ts +269 -0
- package/src/ai.ts +1 -0
- package/src/chat/modules/AgentModule.ts +89 -27
- package/src/chat/modules/SessionsModule.ts +6 -10
- package/src/clients/contextLimits.ts +26 -0
- package/src/clients/pricing/anthropic.ts +12 -0
- package/src/commands/agent.ts +22 -0
- package/src/commands/agents.ts +83 -0
- package/src/commands/misc.ts +6 -0
- package/src/commands/modules.ts +159 -7
- package/src/commands/services.ts +14 -0
- package/src/generate.ts +25 -3
- package/src/services/AgentService.ts +21 -3
- package/src/services/AgentSyncFs.ts +56 -0
- package/src/services/AgentSyncKnowhowWeb.ts +1 -1
- package/src/services/EventService.ts +4 -1
- package/src/services/KnowhowClient.ts +7 -0
- package/src/services/LazyToolsService.ts +7 -3
- package/src/services/MinimalToolsService.ts +15 -7
- package/src/services/SyncedAgentWatcher.ts +2 -0
- package/src/services/SyncerService.ts +35 -6
- package/src/services/Tools.ts +43 -3
- package/src/services/modules/index.ts +127 -46
- package/src/types.ts +5 -0
- package/tests/agents/interrupt.test.ts +11 -18
- package/tests/agents/observe-base.test.ts +183 -0
- package/tests/agents/tools/connectAgent.test.ts +138 -0
- package/tests/agents/tools/observe.test.ts +179 -0
- package/tests/agents/tools/replyToParent.test.ts +125 -0
- package/tests/agents/tools/sendAgentMessage.test.ts +99 -0
- package/tests/agents/tools/waitForAgentCompleted.test.ts +147 -0
- package/tests/chat/detach-attach-rendering.test.ts +315 -0
- package/tests/clients/contextLimits.test.ts +23 -1
- package/tests/clients/pricing.test.ts +20 -1
- package/tests/services/Tools.test.ts +172 -9
- package/tests/unit/modules/moduleLoading.test.ts +69 -0
- package/ts_build/package.json +5 -2
- package/ts_build/src/agents/base/base.d.ts +24 -1
- package/ts_build/src/agents/base/base.js +111 -16
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/agents/patcher/patcher.js +1 -1
- package/ts_build/src/agents/patcher/patcher.js.map +1 -1
- package/ts_build/src/agents/researcher/researcher.js +2 -2
- package/ts_build/src/agents/researcher/researcher.js.map +1 -1
- package/ts_build/src/agents/tools/agentCall.js.map +1 -1
- package/ts_build/src/agents/tools/connectAgent.d.ts +21 -0
- package/ts_build/src/agents/tools/connectAgent.js +284 -0
- package/ts_build/src/agents/tools/connectAgent.js.map +1 -0
- package/ts_build/src/agents/tools/index.d.ts +6 -0
- package/ts_build/src/agents/tools/index.js +6 -0
- package/ts_build/src/agents/tools/index.js.map +1 -1
- package/ts_build/src/agents/tools/list.js +40 -0
- package/ts_build/src/agents/tools/list.js.map +1 -1
- package/ts_build/src/agents/tools/observe.d.ts +20 -0
- package/ts_build/src/agents/tools/observe.js +144 -0
- package/ts_build/src/agents/tools/observe.js.map +1 -0
- package/ts_build/src/agents/tools/replyToParent.d.ts +4 -0
- package/ts_build/src/agents/tools/replyToParent.js +118 -0
- package/ts_build/src/agents/tools/replyToParent.js.map +1 -0
- package/ts_build/src/agents/tools/runGenerate.d.ts +19 -0
- package/ts_build/src/agents/tools/runGenerate.js +138 -0
- package/ts_build/src/agents/tools/runGenerate.js.map +1 -0
- package/ts_build/src/agents/tools/sendAgentMessage.d.ts +11 -0
- package/ts_build/src/agents/tools/sendAgentMessage.js +118 -0
- package/ts_build/src/agents/tools/sendAgentMessage.js.map +1 -0
- package/ts_build/src/agents/tools/startAgentTask.d.ts +7 -0
- package/ts_build/src/agents/tools/startAgentTask.js +53 -6
- package/ts_build/src/agents/tools/startAgentTask.js.map +1 -1
- package/ts_build/src/agents/tools/waitForAgentCompleted.d.ts +10 -0
- package/ts_build/src/agents/tools/waitForAgentCompleted.js +229 -0
- package/ts_build/src/agents/tools/waitForAgentCompleted.js.map +1 -0
- package/ts_build/src/ai.d.ts +1 -0
- package/ts_build/src/ai.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.d.ts +3 -1
- package/ts_build/src/chat/modules/AgentModule.js +46 -21
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/chat/modules/SessionsModule.js +1 -6
- package/ts_build/src/chat/modules/SessionsModule.js.map +1 -1
- package/ts_build/src/clients/contextLimits.js +21 -0
- package/ts_build/src/clients/contextLimits.js.map +1 -1
- package/ts_build/src/clients/pricing/anthropic.d.ts +2 -0
- package/ts_build/src/clients/pricing/anthropic.js +9 -0
- package/ts_build/src/clients/pricing/anthropic.js.map +1 -1
- package/ts_build/src/commands/agent.js +11 -0
- package/ts_build/src/commands/agent.js.map +1 -1
- package/ts_build/src/commands/agents.js +61 -0
- package/ts_build/src/commands/agents.js.map +1 -1
- package/ts_build/src/commands/misc.js +2 -0
- package/ts_build/src/commands/misc.js.map +1 -1
- package/ts_build/src/commands/modules.js +86 -7
- package/ts_build/src/commands/modules.js.map +1 -1
- package/ts_build/src/commands/services.js +10 -0
- package/ts_build/src/commands/services.js.map +1 -1
- package/ts_build/src/generate.d.ts +2 -0
- package/ts_build/src/generate.js +11 -3
- package/ts_build/src/generate.js.map +1 -1
- package/ts_build/src/services/AgentService.d.ts +4 -1
- package/ts_build/src/services/AgentService.js +9 -2
- package/ts_build/src/services/AgentService.js.map +1 -1
- package/ts_build/src/services/AgentSyncFs.d.ts +2 -0
- package/ts_build/src/services/AgentSyncFs.js +35 -0
- package/ts_build/src/services/AgentSyncFs.js.map +1 -1
- package/ts_build/src/services/AgentSyncKnowhowWeb.d.ts +1 -1
- package/ts_build/src/services/EventService.d.ts +4 -1
- package/ts_build/src/services/EventService.js.map +1 -1
- package/ts_build/src/services/KnowhowClient.js +3 -0
- package/ts_build/src/services/KnowhowClient.js.map +1 -1
- package/ts_build/src/services/LazyToolsService.d.ts +2 -2
- package/ts_build/src/services/LazyToolsService.js +2 -2
- package/ts_build/src/services/LazyToolsService.js.map +1 -1
- package/ts_build/src/services/MinimalToolsService.d.ts +2 -2
- package/ts_build/src/services/MinimalToolsService.js +6 -6
- package/ts_build/src/services/MinimalToolsService.js.map +1 -1
- package/ts_build/src/services/SyncedAgentWatcher.d.ts +1 -0
- package/ts_build/src/services/SyncedAgentWatcher.js.map +1 -1
- package/ts_build/src/services/SyncerService.d.ts +2 -1
- package/ts_build/src/services/SyncerService.js +8 -2
- package/ts_build/src/services/SyncerService.js.map +1 -1
- package/ts_build/src/services/Tools.d.ts +6 -1
- package/ts_build/src/services/Tools.js +9 -3
- package/ts_build/src/services/Tools.js.map +1 -1
- package/ts_build/src/services/modules/index.d.ts +1 -1
- package/ts_build/src/services/modules/index.js +67 -27
- package/ts_build/src/services/modules/index.js.map +1 -1
- package/ts_build/src/types.d.ts +3 -0
- package/ts_build/src/types.js.map +1 -1
- package/ts_build/tests/agents/interrupt.test.d.ts +0 -1
- package/ts_build/tests/agents/interrupt.test.js +3 -13
- package/ts_build/tests/agents/interrupt.test.js.map +1 -1
- package/ts_build/tests/agents/observe-base.test.d.ts +1 -0
- package/ts_build/tests/agents/observe-base.test.js +149 -0
- package/ts_build/tests/agents/observe-base.test.js.map +1 -0
- package/ts_build/tests/agents/tools/connectAgent.test.d.ts +1 -0
- package/ts_build/tests/agents/tools/connectAgent.test.js +142 -0
- package/ts_build/tests/agents/tools/connectAgent.test.js.map +1 -0
- package/ts_build/tests/agents/tools/observe.test.d.ts +1 -0
- package/ts_build/tests/agents/tools/observe.test.js +148 -0
- package/ts_build/tests/agents/tools/observe.test.js.map +1 -0
- package/ts_build/tests/agents/tools/replyToParent.test.d.ts +1 -0
- package/ts_build/tests/agents/tools/replyToParent.test.js +116 -0
- package/ts_build/tests/agents/tools/replyToParent.test.js.map +1 -0
- package/ts_build/tests/agents/tools/sendAgentMessage.test.d.ts +1 -0
- package/ts_build/tests/agents/tools/sendAgentMessage.test.js +112 -0
- package/ts_build/tests/agents/tools/sendAgentMessage.test.js.map +1 -0
- package/ts_build/tests/agents/tools/waitForAgentCompleted.test.d.ts +1 -0
- package/ts_build/tests/agents/tools/waitForAgentCompleted.test.js +147 -0
- package/ts_build/tests/agents/tools/waitForAgentCompleted.test.js.map +1 -0
- package/ts_build/tests/chat/detach-attach-rendering.test.d.ts +1 -0
- package/ts_build/tests/chat/detach-attach-rendering.test.js +168 -0
- package/ts_build/tests/chat/detach-attach-rendering.test.js.map +1 -0
- package/ts_build/tests/clients/contextLimits.test.js +11 -1
- package/ts_build/tests/clients/contextLimits.test.js.map +1 -1
- package/ts_build/tests/clients/pricing.test.js +10 -1
- package/ts_build/tests/clients/pricing.test.js.map +1 -1
- package/ts_build/tests/services/Tools.test.js +126 -8
- package/ts_build/tests/services/Tools.test.js.map +1 -1
- package/ts_build/tests/unit/modules/moduleLoading.test.js +84 -0
- package/ts_build/tests/unit/modules/moduleLoading.test.js.map +1 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for BaseAgent.observe() / stopObserving() — the per-agent observation
|
|
3
|
+
* registry that the `observe` tool delegates to.
|
|
4
|
+
*
|
|
5
|
+
* Verifies:
|
|
6
|
+
* 1. A source's emit() is delivered to the agent as a pending message.
|
|
7
|
+
* 2. onlyOnChange dedupes repeated data.
|
|
8
|
+
* 3. maxUpdates auto-stops and runs the source teardown.
|
|
9
|
+
* 4. stopObserving(id) cancels one; stopObserving() cancels all.
|
|
10
|
+
* 5. Observations are scoped to the instance — one agent's stopObserving()
|
|
11
|
+
* with no id does NOT affect another agent's observers.
|
|
12
|
+
* 6. Data emitted after the agent is "done" is not injected.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { BaseAgent, ObservationSource } from "../../src/agents/base/base";
|
|
16
|
+
|
|
17
|
+
// Teardown runs on the microtask queue (Promise.resolve(teardownPromise).then),
|
|
18
|
+
// so flush a couple of ticks before asserting torndown state.
|
|
19
|
+
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
20
|
+
|
|
21
|
+
// Minimal stubs so BaseAgent can be constructed.
|
|
22
|
+
function makeStubs() {
|
|
23
|
+
const events: any = {
|
|
24
|
+
setListener: () => {},
|
|
25
|
+
emit: () => {},
|
|
26
|
+
on: () => {},
|
|
27
|
+
};
|
|
28
|
+
const tools: any = {
|
|
29
|
+
getContext: () => ({}),
|
|
30
|
+
callTool: async () => "",
|
|
31
|
+
};
|
|
32
|
+
return { events, tools };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class TestAgent extends BaseAgent {
|
|
36
|
+
name = "TestAgent";
|
|
37
|
+
description = "test";
|
|
38
|
+
constructor() {
|
|
39
|
+
const { events, tools } = makeStubs();
|
|
40
|
+
super({ Tools: tools, Events: events });
|
|
41
|
+
// Agent must not be "done" for observations to deliver.
|
|
42
|
+
(this as any).status = this.eventTypes.inProgress;
|
|
43
|
+
}
|
|
44
|
+
async getInitialMessages(): Promise<any[]> {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
get delivered(): any[] {
|
|
48
|
+
return (this as any).pendingMessages;
|
|
49
|
+
}
|
|
50
|
+
setStatus(s: string) {
|
|
51
|
+
(this as any).status = s;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A source we can drive manually and that records teardown. */
|
|
56
|
+
function manualSource(label: string) {
|
|
57
|
+
let emitFn: ((d: unknown) => void) | undefined;
|
|
58
|
+
const state = { torndown: false };
|
|
59
|
+
const source: ObservationSource = {
|
|
60
|
+
label,
|
|
61
|
+
start(emit) {
|
|
62
|
+
emitFn = emit;
|
|
63
|
+
return () => {
|
|
64
|
+
state.torndown = true;
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
source,
|
|
70
|
+
push: (d: unknown) => emitFn && emitFn(d),
|
|
71
|
+
state,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("BaseAgent.observe / stopObserving", () => {
|
|
76
|
+
it("delivers emitted data as pending messages", () => {
|
|
77
|
+
const agent = new TestAgent();
|
|
78
|
+
const { source, push } = manualSource("thing");
|
|
79
|
+
const id = agent.observe(source);
|
|
80
|
+
expect(id).toMatch(/^obs_thing_/);
|
|
81
|
+
|
|
82
|
+
push("hello");
|
|
83
|
+
expect(agent.delivered.length).toBe(1);
|
|
84
|
+
expect(String(agent.delivered[0].content)).toContain("hello");
|
|
85
|
+
expect(String(agent.delivered[0].content)).toContain(`[observe:${id}]`);
|
|
86
|
+
agent.stopObserving();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("dedupes identical consecutive data with onlyOnChange (default)", () => {
|
|
90
|
+
const agent = new TestAgent();
|
|
91
|
+
const { source, push } = manualSource("dup");
|
|
92
|
+
agent.observe(source);
|
|
93
|
+
push("a");
|
|
94
|
+
push("a");
|
|
95
|
+
push("b");
|
|
96
|
+
expect(agent.delivered.length).toBe(2);
|
|
97
|
+
agent.stopObserving();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("delivers every emit when onlyOnChange is false", () => {
|
|
101
|
+
const agent = new TestAgent();
|
|
102
|
+
const { source, push } = manualSource("nochange");
|
|
103
|
+
// dedupe of pendingMessages by content still applies in addPendingMessage,
|
|
104
|
+
// so use distinct data to observe count.
|
|
105
|
+
agent.observe(source, { onlyOnChange: false });
|
|
106
|
+
push("x1");
|
|
107
|
+
push("x2");
|
|
108
|
+
push("x3");
|
|
109
|
+
expect(agent.delivered.length).toBe(3);
|
|
110
|
+
agent.stopObserving();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("auto-stops after maxUpdates and runs teardown", async () => {
|
|
114
|
+
const agent = new TestAgent();
|
|
115
|
+
const { source, push, state } = manualSource("cap");
|
|
116
|
+
agent.observe(source, { maxUpdates: 2 });
|
|
117
|
+
push("1");
|
|
118
|
+
push("2"); // hits cap -> stop
|
|
119
|
+
push("3"); // ignored, observer gone
|
|
120
|
+
expect(agent.delivered.length).toBe(2);
|
|
121
|
+
await flush();
|
|
122
|
+
expect(state.torndown).toBe(true);
|
|
123
|
+
agent.stopObserving();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("stopObserving(id) cancels one observer and tears it down", async () => {
|
|
127
|
+
const agent = new TestAgent();
|
|
128
|
+
const a = manualSource("a");
|
|
129
|
+
const b = manualSource("b");
|
|
130
|
+
const idA = agent.observe(a.source);
|
|
131
|
+
agent.observe(b.source);
|
|
132
|
+
const res = agent.stopObserving(idA);
|
|
133
|
+
expect(res).toContain(`Stopped observer ${idA}`);
|
|
134
|
+
await flush();
|
|
135
|
+
expect(a.state.torndown).toBe(true);
|
|
136
|
+
expect(b.state.torndown).toBe(false);
|
|
137
|
+
// b still delivers
|
|
138
|
+
b.push("still here");
|
|
139
|
+
expect(agent.delivered.some((m) => String(m.content).includes("still here"))).toBe(true);
|
|
140
|
+
agent.stopObserving();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("stopObserving() with no id cancels all", async () => {
|
|
144
|
+
const agent = new TestAgent();
|
|
145
|
+
const a = manualSource("a");
|
|
146
|
+
const b = manualSource("b");
|
|
147
|
+
agent.observe(a.source);
|
|
148
|
+
agent.observe(b.source);
|
|
149
|
+
const res = agent.stopObserving();
|
|
150
|
+
expect(res).toMatch(/Stopped 2 active observer/);
|
|
151
|
+
await flush();
|
|
152
|
+
expect(a.state.torndown).toBe(true);
|
|
153
|
+
expect(b.state.torndown).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("is scoped per-agent — one agent's stopObserving does not affect another", async () => {
|
|
157
|
+
const agentA = new TestAgent();
|
|
158
|
+
const agentB = new TestAgent();
|
|
159
|
+
const sa = manualSource("sa");
|
|
160
|
+
const sb = manualSource("sb");
|
|
161
|
+
agentA.observe(sa.source);
|
|
162
|
+
agentB.observe(sb.source);
|
|
163
|
+
|
|
164
|
+
agentA.stopObserving(); // should only affect A
|
|
165
|
+
await flush();
|
|
166
|
+
expect(sa.state.torndown).toBe(true);
|
|
167
|
+
expect(sb.state.torndown).toBe(false);
|
|
168
|
+
|
|
169
|
+
sb.push("b-data");
|
|
170
|
+
expect(agentB.delivered.some((m) => String(m.content).includes("b-data"))).toBe(true);
|
|
171
|
+
agentB.stopObserving();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("does not inject data once the agent is done", () => {
|
|
175
|
+
const agent = new TestAgent();
|
|
176
|
+
const { source, push } = manualSource("late");
|
|
177
|
+
agent.observe(source);
|
|
178
|
+
agent.setStatus(agent.eventTypes.done);
|
|
179
|
+
push("too late");
|
|
180
|
+
expect(agent.delivered.length).toBe(0);
|
|
181
|
+
agent.stopObserving();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import {
|
|
5
|
+
connectAgent,
|
|
6
|
+
stopConnections,
|
|
7
|
+
} from "../../../src/agents/tools/connectAgent";
|
|
8
|
+
|
|
9
|
+
describe("connectAgent / stopConnections", () => {
|
|
10
|
+
let tmpCwd: string;
|
|
11
|
+
let originalCwd: string;
|
|
12
|
+
let agentsDir: string;
|
|
13
|
+
|
|
14
|
+
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
originalCwd = process.cwd();
|
|
18
|
+
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), "connect-agent-"));
|
|
19
|
+
process.chdir(tmpCwd);
|
|
20
|
+
agentsDir = path.join(tmpCwd, ".knowhow", "processes", "agents");
|
|
21
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await stopConnections({});
|
|
26
|
+
process.chdir(originalCwd);
|
|
27
|
+
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function makeTask(taskId: string, threads: any[] = []): string {
|
|
31
|
+
const dir = path.join(agentsDir, taskId);
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
writeThreads(taskId, threads);
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeThreads(taskId: string, threads: any[], extra: any = {}) {
|
|
38
|
+
fs.writeFileSync(
|
|
39
|
+
path.join(agentsDir, taskId, "metadata.json"),
|
|
40
|
+
JSON.stringify({ taskId, threads, ...extra }, null, 2),
|
|
41
|
+
"utf8"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readInput(taskId: string): string {
|
|
46
|
+
const p = path.join(agentsDir, taskId, "input.txt");
|
|
47
|
+
return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
it("rejects empty connections array", async () => {
|
|
51
|
+
const res = await connectAgent({ connections: [] });
|
|
52
|
+
expect(res).toMatch(/non-empty array/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("reports missing task dirs but still wires valid ones", async () => {
|
|
56
|
+
makeTask("a");
|
|
57
|
+
const res = await connectAgent({
|
|
58
|
+
connections: [
|
|
59
|
+
{ listener: "a", speaker: "missing" },
|
|
60
|
+
{ listener: "a", speaker: "a" }, // self connection skipped
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
expect(res).toMatch(/speaker task dir not found: missing/);
|
|
64
|
+
expect(res).toMatch(/self-connection for a/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("relays only NEW assistant messages from speaker to listener", async () => {
|
|
68
|
+
// Speaker starts with one existing assistant message (should NOT be relayed)
|
|
69
|
+
makeTask("listener", []);
|
|
70
|
+
makeTask("speaker", [
|
|
71
|
+
{ role: "assistant", content: "old message" },
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
await connectAgent({
|
|
75
|
+
connections: [{ listener: "listener", speaker: "speaker" }],
|
|
76
|
+
intervalMs: 1000,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Now the speaker produces a new assistant message
|
|
80
|
+
writeThreads("speaker", [
|
|
81
|
+
{ role: "assistant", content: "old message" },
|
|
82
|
+
{ role: "assistant", content: "new insight" },
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
// Wait for a couple of poll cycles
|
|
86
|
+
await delay(1300);
|
|
87
|
+
|
|
88
|
+
const input = readInput("listener");
|
|
89
|
+
expect(input).toContain("[from:speaker] new insight");
|
|
90
|
+
expect(input).not.toContain("old message");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("supports bidirectional in a single call (array of connections)", async () => {
|
|
94
|
+
makeTask("a", []);
|
|
95
|
+
makeTask("b", []);
|
|
96
|
+
|
|
97
|
+
await connectAgent({
|
|
98
|
+
connections: [
|
|
99
|
+
{ listener: "a", speaker: "b" },
|
|
100
|
+
{ listener: "b", speaker: "a" },
|
|
101
|
+
],
|
|
102
|
+
intervalMs: 1000,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
writeThreads("a", [{ role: "assistant", content: "from A" }]);
|
|
106
|
+
writeThreads("b", [{ role: "assistant", content: "from B" }]);
|
|
107
|
+
|
|
108
|
+
await delay(1300);
|
|
109
|
+
|
|
110
|
+
// A hears B, B hears A
|
|
111
|
+
expect(readInput("a")).toContain("[from:b] from B");
|
|
112
|
+
expect(readInput("b")).toContain("[from:a] from A");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("stops a specific connection by id", async () => {
|
|
116
|
+
makeTask("l", []);
|
|
117
|
+
makeTask("s", []);
|
|
118
|
+
const res = await connectAgent({
|
|
119
|
+
connections: [{ listener: "l", speaker: "s" }],
|
|
120
|
+
intervalMs: 1000,
|
|
121
|
+
});
|
|
122
|
+
const idMatch = res.match(/conn_[A-Za-z0-9_]+/);
|
|
123
|
+
expect(idMatch).toBeTruthy();
|
|
124
|
+
const stopRes = await stopConnections({ id: idMatch![0] });
|
|
125
|
+
expect(stopRes).toMatch(/Stopped connection/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("stopConnections() with no id stops all", async () => {
|
|
129
|
+
makeTask("l2", []);
|
|
130
|
+
makeTask("s2", []);
|
|
131
|
+
await connectAgent({
|
|
132
|
+
connections: [{ listener: "l2", speaker: "s2" }],
|
|
133
|
+
intervalMs: 1000,
|
|
134
|
+
});
|
|
135
|
+
const res = await stopConnections({});
|
|
136
|
+
expect(res).toMatch(/Stopped 1 active connection/);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { observe, stopObserving } from "../../../src/agents/tools/observe";
|
|
2
|
+
import { ObservationSource } from "../../../src/agents/base/base";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* observe is a self-referential tool: it reads the calling agent + ToolsService
|
|
6
|
+
* off _ctx, builds a tool-poll ObservationSource, and delegates to the calling
|
|
7
|
+
* agent's own observe()/stopObserving() methods (which own the per-agent
|
|
8
|
+
* observation registry). These tests use a fake caller that implements the same
|
|
9
|
+
* per-agent observe()/stopObserving() contract as BaseAgent.
|
|
10
|
+
*/
|
|
11
|
+
describe("observe / stopObserving", () => {
|
|
12
|
+
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
13
|
+
|
|
14
|
+
// Minimal faithful reimplementation of BaseAgent.observe/stopObserving so the
|
|
15
|
+
// delegation is exercised end-to-end without constructing a full BaseAgent.
|
|
16
|
+
function makeCaller() {
|
|
17
|
+
const messages: any[] = [];
|
|
18
|
+
const observations = new Map<string, any>();
|
|
19
|
+
let seq = 0;
|
|
20
|
+
const done = "done";
|
|
21
|
+
const caller: any = {
|
|
22
|
+
currentTaskId: "task-x",
|
|
23
|
+
name: "Tester",
|
|
24
|
+
status: "in_progress",
|
|
25
|
+
getEnabledToolNames: () => ["fakeTool"],
|
|
26
|
+
addPendingMessage: (m: any) => messages.push(m),
|
|
27
|
+
observe(source: ObservationSource, opts: any = {}) {
|
|
28
|
+
const {
|
|
29
|
+
onlyOnChange = true,
|
|
30
|
+
maxUpdates = 20,
|
|
31
|
+
maxDurationMs = 10 * 60 * 1000,
|
|
32
|
+
} = opts;
|
|
33
|
+
seq += 1;
|
|
34
|
+
const safeLabel = source.label.replace(/[^A-Za-z0-9]+/g, "_").slice(0, 24);
|
|
35
|
+
const id = `obs_${safeLabel}_${Date.now()}_${seq}`;
|
|
36
|
+
let last: string | undefined;
|
|
37
|
+
const emit = (datum: unknown) => {
|
|
38
|
+
if (caller.status === done) {
|
|
39
|
+
caller.stopObserving(id);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const str =
|
|
43
|
+
typeof datum === "string" ? datum : JSON.stringify(datum);
|
|
44
|
+
if (onlyOnChange && str === last) return;
|
|
45
|
+
last = str;
|
|
46
|
+
const obs = observations.get(id);
|
|
47
|
+
if (!obs) return;
|
|
48
|
+
obs.updates += 1;
|
|
49
|
+
caller.addPendingMessage({
|
|
50
|
+
role: "user",
|
|
51
|
+
content: `[observe:${id}] ${source.label} (update ${obs.updates}/${maxUpdates}):\n${str}`,
|
|
52
|
+
});
|
|
53
|
+
if (obs.updates >= maxUpdates) caller.stopObserving(id, "max updates reached");
|
|
54
|
+
};
|
|
55
|
+
const teardownPromise = Promise.resolve(source.start(emit));
|
|
56
|
+
const expiry = maxDurationMs
|
|
57
|
+
? setTimeout(() => caller.stopObserving(id, "max duration reached"), maxDurationMs)
|
|
58
|
+
: undefined;
|
|
59
|
+
expiry?.unref?.();
|
|
60
|
+
observations.set(id, { id, label: source.label, updates: 0, teardownPromise, expiry });
|
|
61
|
+
return id;
|
|
62
|
+
},
|
|
63
|
+
stopObserving(id?: string, reason = "stopped") {
|
|
64
|
+
const ids = id ? [id] : [...observations.keys()];
|
|
65
|
+
let stopped = 0;
|
|
66
|
+
for (const oid of ids) {
|
|
67
|
+
const obs = observations.get(oid);
|
|
68
|
+
if (!obs) continue;
|
|
69
|
+
if (obs.expiry) clearTimeout(obs.expiry);
|
|
70
|
+
Promise.resolve(obs.teardownPromise).then(
|
|
71
|
+
(fn: any) => typeof fn === "function" && fn()
|
|
72
|
+
);
|
|
73
|
+
observations.delete(oid);
|
|
74
|
+
stopped += 1;
|
|
75
|
+
}
|
|
76
|
+
return id
|
|
77
|
+
? stopped
|
|
78
|
+
? `Stopped observer ${id} (${reason}).`
|
|
79
|
+
: `No active observer found with id: ${id}.`
|
|
80
|
+
: `Stopped ${stopped} active observer(s) (${reason}).`;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return { caller, messages };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function makeCtx(toolResults: string[]) {
|
|
87
|
+
const { caller, messages } = makeCaller();
|
|
88
|
+
let call = 0;
|
|
89
|
+
const Tools = {
|
|
90
|
+
callTool: jest.fn(async () => {
|
|
91
|
+
const r = toolResults[Math.min(call, toolResults.length - 1)];
|
|
92
|
+
call++;
|
|
93
|
+
return r;
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
return { _ctx: { caller, Tools }, messages, Tools, caller };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
it("requires toolName", async () => {
|
|
100
|
+
const res = await observe({ toolName: "" });
|
|
101
|
+
expect(res).toMatch(/toolName is required/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("errors without a ToolsService on _ctx", async () => {
|
|
105
|
+
const res = await observe({ toolName: "fakeTool", _ctx: { caller: {} } });
|
|
106
|
+
expect(res).toMatch(/no ToolsService/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("errors without a caller on _ctx", async () => {
|
|
110
|
+
const res = await observe({
|
|
111
|
+
toolName: "fakeTool",
|
|
112
|
+
_ctx: { Tools: { callTool: jest.fn() } as any },
|
|
113
|
+
});
|
|
114
|
+
expect(res).toMatch(/no calling agent/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("polls the tool and delivers changed results to the caller", async () => {
|
|
118
|
+
const { _ctx, messages, Tools } = makeCtx(["a", "b", "b", "c"]);
|
|
119
|
+
|
|
120
|
+
const res = await observe({
|
|
121
|
+
toolName: "fakeTool",
|
|
122
|
+
intervalMs: 500, // clamped to min 500
|
|
123
|
+
maxUpdates: 4,
|
|
124
|
+
_ctx: _ctx as any,
|
|
125
|
+
});
|
|
126
|
+
expect(res).toMatch(/Started observing/);
|
|
127
|
+
|
|
128
|
+
// Wait for ~4 polls
|
|
129
|
+
await delay(2400);
|
|
130
|
+
|
|
131
|
+
// Tool should have been called several times
|
|
132
|
+
expect(Tools.callTool).toHaveBeenCalled();
|
|
133
|
+
// Delivered updates: only changed values (a, b, c) => 3 update messages.
|
|
134
|
+
const updateMsgs = messages.filter((m) =>
|
|
135
|
+
String(m.content).includes("[observe:")
|
|
136
|
+
);
|
|
137
|
+
expect(updateMsgs.length).toBeGreaterThanOrEqual(3);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("can be stopped early via stopObserving with the returned id", async () => {
|
|
141
|
+
const { _ctx, messages } = makeCtx(["x", "y", "z"]);
|
|
142
|
+
const res = await observe({
|
|
143
|
+
toolName: "fakeTool",
|
|
144
|
+
intervalMs: 500,
|
|
145
|
+
maxUpdates: 20,
|
|
146
|
+
_ctx: _ctx as any,
|
|
147
|
+
});
|
|
148
|
+
const idMatch = res.match(/id: (obs_[^)]+)\)/);
|
|
149
|
+
expect(idMatch).toBeTruthy();
|
|
150
|
+
const id = idMatch![1];
|
|
151
|
+
|
|
152
|
+
await delay(600);
|
|
153
|
+
const stopRes = await stopObserving({ id, _ctx: _ctx as any });
|
|
154
|
+
expect(stopRes).toContain(`Stopped observer ${id}`);
|
|
155
|
+
|
|
156
|
+
const before = messages.length;
|
|
157
|
+
await delay(1000);
|
|
158
|
+
// No further updates after stopping.
|
|
159
|
+
expect(messages.length).toBe(before);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("stopObserving with no id stops all observers on that agent", async () => {
|
|
163
|
+
const { _ctx } = makeCtx(["1"]);
|
|
164
|
+
await observe({ toolName: "fakeTool", intervalMs: 1000, _ctx: _ctx as any });
|
|
165
|
+
const res = await stopObserving({ _ctx: _ctx as any });
|
|
166
|
+
expect(res).toMatch(/Stopped \d+ active observer/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("stopObserving on an unknown id returns a helpful message", async () => {
|
|
170
|
+
const { _ctx } = makeCtx(["1"]);
|
|
171
|
+
const res = await stopObserving({ id: "obs_does_not_exist", _ctx: _ctx as any });
|
|
172
|
+
expect(res).toMatch(/No active observer found/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("errors without a caller on _ctx for stopObserving", async () => {
|
|
176
|
+
const res = await stopObserving({ id: "x" });
|
|
177
|
+
expect(res).toMatch(/no calling agent/);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { replyToParent } from "../../../src/agents/tools/replyToParent";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* These tests exercise the `_ctx`-based self-referential mechanism:
|
|
8
|
+
* replyToParent discovers who is calling (and which parent spawned them)
|
|
9
|
+
* purely from the per-call context object threaded in as the last positional
|
|
10
|
+
* argument by ToolsService.callTool.
|
|
11
|
+
*/
|
|
12
|
+
describe("replyToParent (_ctx self-referential tool)", () => {
|
|
13
|
+
let tmpCwd: string;
|
|
14
|
+
let originalCwd: string;
|
|
15
|
+
let agentsDir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
originalCwd = process.cwd();
|
|
19
|
+
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), "reply-parent-"));
|
|
20
|
+
process.chdir(tmpCwd);
|
|
21
|
+
agentsDir = path.join(tmpCwd, ".knowhow", "processes", "agents");
|
|
22
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
process.chdir(originalCwd);
|
|
27
|
+
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function makeTask(taskId: string, meta: Record<string, any> = {}) {
|
|
31
|
+
const dir = path.join(agentsDir, taskId);
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
fs.writeFileSync(path.join(dir, "input.txt"), "", "utf8");
|
|
34
|
+
fs.writeFileSync(
|
|
35
|
+
path.join(dir, "metadata.json"),
|
|
36
|
+
JSON.stringify({ taskId, ...meta }, null, 2),
|
|
37
|
+
"utf8"
|
|
38
|
+
);
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
it("returns a helpful message when there is no parent", async () => {
|
|
43
|
+
makeTask("child-1"); // no parentTaskId
|
|
44
|
+
const result = await replyToParent("hi", { taskId: "child-1" });
|
|
45
|
+
expect(result).toMatch(/No parent task found/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("uses parentTaskId directly from _ctx when provided", async () => {
|
|
49
|
+
const parentDir = makeTask("parent-1");
|
|
50
|
+
makeTask("child-2", { parentTaskId: "parent-1" });
|
|
51
|
+
|
|
52
|
+
const result = await replyToParent("progress update", {
|
|
53
|
+
taskId: "child-2",
|
|
54
|
+
parentTaskId: "parent-1",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(result).toMatch(/Sent message to parent task parent-1/);
|
|
58
|
+
const parentInput = fs.readFileSync(
|
|
59
|
+
path.join(parentDir, "input.txt"),
|
|
60
|
+
"utf8"
|
|
61
|
+
);
|
|
62
|
+
expect(parentInput).toContain("progress update");
|
|
63
|
+
expect(parentInput).toContain("[from:child-2]");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("falls back to reading parentTaskId from the caller's metadata.json", async () => {
|
|
67
|
+
const parentDir = makeTask("parent-2");
|
|
68
|
+
makeTask("child-3", { parentTaskId: "parent-2" });
|
|
69
|
+
|
|
70
|
+
// No parentTaskId in _ctx — must be discovered from child-3's metadata.
|
|
71
|
+
const result = await replyToParent("need help", { taskId: "child-3" });
|
|
72
|
+
|
|
73
|
+
expect(result).toMatch(/Sent message to parent task parent-2/);
|
|
74
|
+
const parentInput = fs.readFileSync(
|
|
75
|
+
path.join(parentDir, "input.txt"),
|
|
76
|
+
"utf8"
|
|
77
|
+
);
|
|
78
|
+
expect(parentInput).toContain("need help");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("reads taskId/name off _ctx.caller when taskId not set directly", async () => {
|
|
82
|
+
const parentDir = makeTask("parent-3");
|
|
83
|
+
makeTask("child-4", { parentTaskId: "parent-3" });
|
|
84
|
+
|
|
85
|
+
const fakeAgent = { currentTaskId: "child-4", name: "Researcher" };
|
|
86
|
+
const result = await replyToParent("done researching", {
|
|
87
|
+
caller: fakeAgent,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(result).toMatch(/Sent message to parent task parent-3/);
|
|
91
|
+
const parentInput = fs.readFileSync(
|
|
92
|
+
path.join(parentDir, "input.txt"),
|
|
93
|
+
"utf8"
|
|
94
|
+
);
|
|
95
|
+
expect(parentInput).toContain("[agent:Researcher]");
|
|
96
|
+
expect(parentInput).toContain("done researching");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("appends rather than clobbers existing parent input", async () => {
|
|
100
|
+
const parentDir = makeTask("parent-4");
|
|
101
|
+
fs.writeFileSync(
|
|
102
|
+
path.join(parentDir, "input.txt"),
|
|
103
|
+
"existing message\n",
|
|
104
|
+
"utf8"
|
|
105
|
+
);
|
|
106
|
+
makeTask("child-5", { parentTaskId: "parent-4" });
|
|
107
|
+
|
|
108
|
+
await replyToParent("second message", {
|
|
109
|
+
taskId: "child-5",
|
|
110
|
+
parentTaskId: "parent-4",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const parentInput = fs.readFileSync(
|
|
114
|
+
path.join(parentDir, "input.txt"),
|
|
115
|
+
"utf8"
|
|
116
|
+
);
|
|
117
|
+
expect(parentInput).toContain("existing message");
|
|
118
|
+
expect(parentInput).toContain("second message");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns error for empty message", async () => {
|
|
122
|
+
const result = await replyToParent(" ", { taskId: "whatever" });
|
|
123
|
+
expect(result).toMatch(/No message provided/);
|
|
124
|
+
});
|
|
125
|
+
});
|