@tyvm/knowhow 0.0.154 → 0.0.155
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/package.json +1 -1
- package/src/agents/base/base.ts +44 -26
- package/src/agents/base/prompt.ts +3 -2
- package/src/auth/keyAuth.ts +35 -3
- package/src/chat/modules/AgentModule.ts +98 -42
- package/src/clients/index.ts +7 -4
- package/src/clients/openai.ts +37 -4
- package/src/services/KnowhowClient.ts +6 -5
- package/src/services/watchers/FsSyncer.ts +40 -2
- package/src/services/watchers/RemoteSyncer.ts +40 -2
- package/src/tunnel.ts +18 -12
- package/src/worker.ts +62 -12
- package/tests/clients/reasoningEffort.test.ts +57 -0
- package/tests/unit/auth/keyAuth.test.ts +31 -2
- package/tests/unit/services/KnowhowClient.test.ts +84 -0
- package/tests/unit/services/SyncedAgentWatcher.test.ts +89 -0
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/base/base.d.ts +1 -0
- package/ts_build/src/agents/base/base.js +27 -22
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/agents/base/prompt.d.ts +1 -1
- package/ts_build/src/agents/base/prompt.js +3 -2
- package/ts_build/src/agents/base/prompt.js.map +1 -1
- package/ts_build/src/auth/keyAuth.d.ts +1 -0
- package/ts_build/src/auth/keyAuth.js +19 -2
- package/ts_build/src/auth/keyAuth.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.d.ts +3 -0
- package/ts_build/src/chat/modules/AgentModule.js +74 -34
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/clients/index.js +7 -4
- package/ts_build/src/clients/index.js.map +1 -1
- package/ts_build/src/clients/openai.js +29 -4
- package/ts_build/src/clients/openai.js.map +1 -1
- package/ts_build/src/services/KnowhowClient.js +5 -3
- package/ts_build/src/services/KnowhowClient.js.map +1 -1
- package/ts_build/src/services/watchers/FsSyncer.d.ts +4 -0
- package/ts_build/src/services/watchers/FsSyncer.js +36 -2
- package/ts_build/src/services/watchers/FsSyncer.js.map +1 -1
- package/ts_build/src/services/watchers/RemoteSyncer.d.ts +4 -0
- package/ts_build/src/services/watchers/RemoteSyncer.js +36 -2
- package/ts_build/src/services/watchers/RemoteSyncer.js.map +1 -1
- package/ts_build/src/tunnel.js +11 -8
- package/ts_build/src/tunnel.js.map +1 -1
- package/ts_build/src/worker.js +47 -10
- package/ts_build/src/worker.js.map +1 -1
- package/ts_build/tests/clients/reasoningEffort.test.js +47 -0
- package/ts_build/tests/clients/reasoningEffort.test.js.map +1 -1
- package/ts_build/tests/unit/auth/keyAuth.test.js +22 -2
- package/ts_build/tests/unit/auth/keyAuth.test.js.map +1 -1
- package/ts_build/tests/unit/services/KnowhowClient.test.d.ts +1 -0
- package/ts_build/tests/unit/services/KnowhowClient.test.js +69 -0
- package/ts_build/tests/unit/services/KnowhowClient.test.js.map +1 -0
- package/ts_build/tests/unit/services/SyncedAgentWatcher.test.d.ts +1 -0
- package/ts_build/tests/unit/services/SyncedAgentWatcher.test.js +78 -0
- package/ts_build/tests/unit/services/SyncedAgentWatcher.test.js.map +1 -0
|
@@ -16,7 +16,9 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
|
|
|
16
16
|
public taskId: string = "";
|
|
17
17
|
private client: KnowhowSimpleClient;
|
|
18
18
|
private pollInterval: NodeJS.Timeout | null = null;
|
|
19
|
+
private lastThreadIndex: number = -1;
|
|
19
20
|
private lastThreadLength: number = 0;
|
|
21
|
+
private lastMessageJson: string | null = null;
|
|
20
22
|
public agentName: string = "remote-agent";
|
|
21
23
|
private stopped: boolean = false;
|
|
22
24
|
public agentEvents = new EventService();
|
|
@@ -41,8 +43,10 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
|
|
|
41
43
|
const details = await this.client.getTaskDetails(taskId);
|
|
42
44
|
const threads: any[][] = details?.data?.threads || [];
|
|
43
45
|
const lastThread = threads[threads.length - 1] || [];
|
|
46
|
+
this.lastThreadIndex = threads.length - 1;
|
|
44
47
|
this.agentName = "remote-agent";
|
|
45
48
|
this.lastThreadLength = lastThread.length;
|
|
49
|
+
this.lastMessageJson = this.messageJson(lastThread[lastThread.length - 1]);
|
|
46
50
|
} catch (err: any) {
|
|
47
51
|
console.warn(
|
|
48
52
|
`⚠️ Could not load initial state for task ${taskId}: ${err.message}`
|
|
@@ -68,8 +72,26 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
|
|
|
68
72
|
const details = await this.client.getTaskDetails(this.taskId);
|
|
69
73
|
const threads: any[][] = details?.data?.threads || [];
|
|
70
74
|
const lastThread = threads[threads.length - 1] || [];
|
|
75
|
+
const threadIndex = threads.length - 1;
|
|
76
|
+
|
|
77
|
+
let newMessages: any[];
|
|
78
|
+
if (
|
|
79
|
+
threadIndex === this.lastThreadIndex &&
|
|
80
|
+
lastThread.length >= this.lastThreadLength
|
|
81
|
+
) {
|
|
82
|
+
newMessages = lastThread.slice(this.lastThreadLength);
|
|
83
|
+
} else {
|
|
84
|
+
const retainedIndex = this.findLastMessage(
|
|
85
|
+
lastThread,
|
|
86
|
+
this.lastMessageJson
|
|
87
|
+
);
|
|
88
|
+
newMessages = lastThread.slice(retainedIndex + 1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.lastThreadIndex = threadIndex;
|
|
92
|
+
this.lastThreadLength = lastThread.length;
|
|
93
|
+
this.lastMessageJson = this.messageJson(lastThread[lastThread.length - 1]);
|
|
71
94
|
|
|
72
|
-
const newMessages = lastThread.slice(this.lastThreadLength);
|
|
73
95
|
if (newMessages.length > 0) {
|
|
74
96
|
const renderEvents = messagesToRenderEvents(
|
|
75
97
|
newMessages,
|
|
@@ -93,7 +115,6 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
|
|
|
93
115
|
}
|
|
94
116
|
}
|
|
95
117
|
this.agentEvents.emit(this.eventTypes.threadUpdate, lastThread);
|
|
96
|
-
this.lastThreadLength = lastThread.length;
|
|
97
118
|
}
|
|
98
119
|
|
|
99
120
|
// Stop polling and emit done if task is complete with a result
|
|
@@ -155,4 +176,21 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
|
|
|
155
176
|
await this.client.sendMessageToAgent(this.taskId, `/poke ${message || ""}`.trim());
|
|
156
177
|
console.log(`🫵 Interrupt sent to remote web agent: ${this.taskId}`);
|
|
157
178
|
}
|
|
179
|
+
|
|
180
|
+
private messageJson(message: any): string | null {
|
|
181
|
+
if (message == null) return null;
|
|
182
|
+
try {
|
|
183
|
+
return JSON.stringify(message);
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private findLastMessage(messages: any[], messageJson: string | null): number {
|
|
190
|
+
if (messageJson == null) return -1;
|
|
191
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
192
|
+
if (this.messageJson(messages[index]) === messageJson) return index;
|
|
193
|
+
}
|
|
194
|
+
return -1;
|
|
195
|
+
}
|
|
158
196
|
}
|
package/src/tunnel.ts
CHANGED
|
@@ -10,10 +10,12 @@ import { WorkerPasskeyAuthService } from "./workers/auth/WorkerPasskeyAuth";
|
|
|
10
10
|
import { WsMiddlewareStack } from "./workers/auth/WsMiddleware";
|
|
11
11
|
import { makeAuthMiddleware } from "./workers/auth/authMiddleware";
|
|
12
12
|
|
|
13
|
+
const PROD_TUNNEL_WORKER_DOMAIN = "worker.tyvm-apps.com";
|
|
14
|
+
|
|
13
15
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* Resolve the tunnel domain and protocol from the environment and API URL.
|
|
17
|
+
* TUNNEL_WORKER_DOMAIN takes precedence. Otherwise dev API URLs use the dev
|
|
18
|
+
* tunnel domain and all other API URLs use the production tunnel domain.
|
|
17
19
|
*/
|
|
18
20
|
export function extractTunnelDomain(apiUrl: string): {
|
|
19
21
|
domain: string;
|
|
@@ -22,18 +24,22 @@ export function extractTunnelDomain(apiUrl: string): {
|
|
|
22
24
|
try {
|
|
23
25
|
const url = new URL(apiUrl);
|
|
24
26
|
const useHttps = url.protocol === "https:";
|
|
27
|
+
const configuredDomain = process.env.TUNNEL_WORKER_DOMAIN?.trim();
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
return { domain: `worker.${url.hostname}`, useHttps };
|
|
29
|
+
const isDevApi = url.hostname.split(".").includes("dev");
|
|
30
|
+
const domain =
|
|
31
|
+
configuredDomain ||
|
|
32
|
+
(isDevApi ? `worker.dev.tyvm-apps.com` : PROD_TUNNEL_WORKER_DOMAIN);
|
|
33
|
+
|
|
34
|
+
return { domain, useHttps };
|
|
34
35
|
} catch (err) {
|
|
35
36
|
console.error("Failed to parse API_URL for tunnel domain:", err);
|
|
36
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
domain:
|
|
39
|
+
process.env.TUNNEL_WORKER_DOMAIN?.trim() ||
|
|
40
|
+
PROD_TUNNEL_WORKER_DOMAIN,
|
|
41
|
+
useHttps: false,
|
|
42
|
+
};
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
45
|
|
package/src/worker.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { loadJwt } from "./login";
|
|
|
6
6
|
import { services } from "./services";
|
|
7
7
|
import { PasskeySetupService } from "./workers/auth/PasskeySetup";
|
|
8
8
|
import { WorkerPasskeyAuthService } from "./workers/auth/WorkerPasskeyAuth";
|
|
9
|
+
import { authenticateWithKey, hasKeyPair } from "./auth/keyAuth";
|
|
9
10
|
import {
|
|
10
11
|
makeUnlockTool,
|
|
11
12
|
makeLockTool,
|
|
@@ -303,8 +304,8 @@ export async function worker(options?: {
|
|
|
303
304
|
let connected = false;
|
|
304
305
|
let tunnelHandler: TunnelHandler | null = null;
|
|
305
306
|
let tunnelWs: WebSocket | null = null;
|
|
306
|
-
let lastJwt: string | null = null;
|
|
307
307
|
let unauthorizedJwt: string | null = null;
|
|
308
|
+
let refreshAttemptedJwt: string | null = null;
|
|
308
309
|
|
|
309
310
|
// ---------------------------------------------------------------------------
|
|
310
311
|
// Liveness watchdog state
|
|
@@ -353,7 +354,6 @@ export async function worker(options?: {
|
|
|
353
354
|
async function connectWebSocket() {
|
|
354
355
|
const jwt = await loadJwt();
|
|
355
356
|
console.log(`Connecting to ${API_URL}`);
|
|
356
|
-
lastJwt = jwt;
|
|
357
357
|
|
|
358
358
|
// Reset the MCP server to avoid "Already connected to a transport" error on reconnects
|
|
359
359
|
await mcpServer.reset();
|
|
@@ -476,11 +476,17 @@ export async function worker(options?: {
|
|
|
476
476
|
},
|
|
477
477
|
onClose: (code, _reason) => {
|
|
478
478
|
if (code === 1008) {
|
|
479
|
-
unauthorizedJwt =
|
|
479
|
+
unauthorizedJwt = jwt;
|
|
480
|
+
// The worker and tunnel sockets share a JWT and must be renewed as
|
|
481
|
+
// one connection cycle. Ensure a later worker "open" event cannot
|
|
482
|
+
// leave us connected with a permanently closed tunnel.
|
|
483
|
+
if (ws.readyState !== WebSocket.CLOSED) {
|
|
484
|
+
ws.terminate();
|
|
485
|
+
}
|
|
480
486
|
console.error(
|
|
481
487
|
"❌ Tunnel received Unauthorized (1008). The JWT may be expired."
|
|
482
488
|
);
|
|
483
|
-
console.error("
|
|
489
|
+
console.error(" Attempting to refresh the JWT with the CLI identity...");
|
|
484
490
|
} else {
|
|
485
491
|
console.log(
|
|
486
492
|
"Tunnel connection will reconnect on next connection cycle..."
|
|
@@ -532,14 +538,16 @@ export async function worker(options?: {
|
|
|
532
538
|
// If we got an Unauthorized (1008) close, record the JWT that failed
|
|
533
539
|
// so we don't keep hammering the server with the same expired token
|
|
534
540
|
if (code === 1008) {
|
|
535
|
-
unauthorizedJwt =
|
|
541
|
+
unauthorizedJwt = jwt;
|
|
542
|
+
if (tunnelConnection?.readyState !== WebSocket.CLOSED) {
|
|
543
|
+
tunnelConnection?.terminate();
|
|
544
|
+
}
|
|
536
545
|
console.error(
|
|
537
546
|
"❌ Worker received Unauthorized (1008). The JWT may be expired."
|
|
538
547
|
);
|
|
539
548
|
console.error(
|
|
540
|
-
"
|
|
549
|
+
" Attempting to refresh the JWT with the CLI identity..."
|
|
541
550
|
);
|
|
542
|
-
console.error(" Pausing reconnection until JWT changes...");
|
|
543
551
|
} else {
|
|
544
552
|
console.log("Attempting to reconnect...");
|
|
545
553
|
}
|
|
@@ -566,17 +574,59 @@ export async function worker(options?: {
|
|
|
566
574
|
|
|
567
575
|
while (true) {
|
|
568
576
|
if (!connected) {
|
|
569
|
-
//
|
|
577
|
+
// A registered CLI identity can renew an expired JWT without requiring an
|
|
578
|
+
// interactive login. Attempt this once per rejected token; if it fails,
|
|
579
|
+
// continue watching the JWT file so `knowhow login` in another shell works.
|
|
570
580
|
if (unauthorizedJwt !== null) {
|
|
571
|
-
|
|
581
|
+
let currentJwt = await loadJwt().catch(() => null);
|
|
572
582
|
if (currentJwt === unauthorizedJwt) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
583
|
+
let attemptedRefreshNow = false;
|
|
584
|
+
if (refreshAttemptedJwt !== currentJwt) {
|
|
585
|
+
refreshAttemptedJwt = currentJwt;
|
|
586
|
+
attemptedRefreshNow = true;
|
|
587
|
+
const freshConfig = await getConfig();
|
|
588
|
+
const identityPath = freshConfig.cliIdentityPath;
|
|
589
|
+
if (freshConfig.orgId && hasKeyPair(identityPath)) {
|
|
590
|
+
try {
|
|
591
|
+
const refreshed = await authenticateWithKey(
|
|
592
|
+
freshConfig.orgId,
|
|
593
|
+
API_URL,
|
|
594
|
+
identityPath
|
|
595
|
+
);
|
|
596
|
+
if (refreshed) {
|
|
597
|
+
currentJwt = await loadJwt();
|
|
598
|
+
console.log("✅ JWT refreshed automatically; reconnecting...");
|
|
599
|
+
} else {
|
|
600
|
+
console.error(
|
|
601
|
+
"❌ CLI identity was rejected and could not refresh the JWT."
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
} catch (error) {
|
|
605
|
+
const message =
|
|
606
|
+
error instanceof Error ? error.message : String(error);
|
|
607
|
+
console.error(`❌ Automatic JWT refresh failed: ${message}`);
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
console.error(
|
|
611
|
+
"❌ No registered CLI identity is available for automatic JWT refresh."
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (currentJwt === unauthorizedJwt) {
|
|
617
|
+
if (attemptedRefreshNow) {
|
|
618
|
+
console.error(
|
|
619
|
+
" Run 'knowhow login' in another shell; reconnection will resume when the JWT changes."
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
await wait(5000);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
576
625
|
}
|
|
577
626
|
// JWT changed - clear the unauthorized state and reconnect
|
|
578
627
|
console.log("🔄 JWT has changed, attempting to reconnect...");
|
|
579
628
|
unauthorizedJwt = null;
|
|
629
|
+
refreshAttemptedJwt = null;
|
|
580
630
|
}
|
|
581
631
|
|
|
582
632
|
console.log("Attempting to connect...");
|
|
@@ -45,6 +45,63 @@ describe("reasoning effort values", () => {
|
|
|
45
45
|
expect(client.resolveReasoningEffortForModel(options("unknown", "max"))).toBe("xhigh");
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it("explicitly disables reasoning in Responses API requests", async () => {
|
|
49
|
+
const client = new GenericOpenAiClient("fake-key");
|
|
50
|
+
const create = jest.fn().mockResolvedValue({
|
|
51
|
+
id: "resp_disabled",
|
|
52
|
+
status: "completed",
|
|
53
|
+
output: [{ type: "message", content: [{ type: "output_text", text: "summary" }] }],
|
|
54
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
55
|
+
});
|
|
56
|
+
(client.client as any).responses.create = create;
|
|
57
|
+
|
|
58
|
+
await client.createChatResponse({
|
|
59
|
+
model: Models.openai.GPT_56_Sol,
|
|
60
|
+
messages: [{ role: "user", content: "summarize" }],
|
|
61
|
+
reasoning_effort: "none",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(create).toHaveBeenCalledWith(
|
|
65
|
+
expect.objectContaining({ reasoning: { effort: "none" } })
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("diagnoses empty Responses output but not valid tool-only output", async () => {
|
|
70
|
+
const client = new GenericOpenAiClient("fake-key");
|
|
71
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
72
|
+
const create = jest.fn()
|
|
73
|
+
.mockResolvedValueOnce({
|
|
74
|
+
id: "resp_empty",
|
|
75
|
+
status: "incomplete",
|
|
76
|
+
incomplete_details: { reason: "max_output_tokens" },
|
|
77
|
+
output: [{ type: "reasoning", summary: [] }],
|
|
78
|
+
usage: { input_tokens: 2, output_tokens: 3 },
|
|
79
|
+
})
|
|
80
|
+
.mockResolvedValueOnce({
|
|
81
|
+
id: "resp_tool",
|
|
82
|
+
status: "completed",
|
|
83
|
+
output: [{
|
|
84
|
+
type: "function_call", call_id: "call_1", name: "readFile", arguments: "{}",
|
|
85
|
+
}],
|
|
86
|
+
usage: { input_tokens: 2, output_tokens: 3 },
|
|
87
|
+
});
|
|
88
|
+
(client.client as any).responses.create = create;
|
|
89
|
+
const request = {
|
|
90
|
+
model: Models.openai.GPT_56_Sol,
|
|
91
|
+
messages: [{ role: "user" as const, content: "continue" }],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
await client.createChatResponse(request);
|
|
95
|
+
expect(warn).toHaveBeenCalledWith(
|
|
96
|
+
"[Responses API] Response contained no text or tool calls",
|
|
97
|
+
expect.objectContaining({ responseId: "resp_empty", status: "incomplete" })
|
|
98
|
+
);
|
|
99
|
+
warn.mockClear();
|
|
100
|
+
await client.createChatResponse(request);
|
|
101
|
+
expect(warn).not.toHaveBeenCalled();
|
|
102
|
+
warn.mockRestore();
|
|
103
|
+
});
|
|
104
|
+
|
|
48
105
|
it("maps all values to valid Gemini thinking levels and budgets", () => {
|
|
49
106
|
const client = new GenericGeminiClient("fake-key");
|
|
50
107
|
const levelModel = GoogleThinkingLevelModels.find(
|
|
@@ -14,6 +14,7 @@ jest.mock("../../../src/utils/http", () => ({
|
|
|
14
14
|
}));
|
|
15
15
|
|
|
16
16
|
jest.mock("../../../src/auth/keyManager", () => ({
|
|
17
|
+
getDefaultPrivateKeyPath: () => "/keys/default",
|
|
17
18
|
keyPairExists,
|
|
18
19
|
loadKeyPair,
|
|
19
20
|
signMessage,
|
|
@@ -25,6 +26,7 @@ jest.mock("../../../src/auth/browserLogin", () => ({
|
|
|
25
26
|
|
|
26
27
|
import {
|
|
27
28
|
authenticateWithKey,
|
|
29
|
+
exchangeAvailablePublicKeyJwt,
|
|
28
30
|
exchangePublicKeyJwt,
|
|
29
31
|
hasKeyPair,
|
|
30
32
|
registerPublicKey,
|
|
@@ -69,14 +71,41 @@ describe("keyAuth", () => {
|
|
|
69
71
|
]);
|
|
70
72
|
});
|
|
71
73
|
|
|
74
|
+
it("uses the registered default identity when a configured identity is rejected", async () => {
|
|
75
|
+
post
|
|
76
|
+
.mockRejectedValueOnce({ status: 401 })
|
|
77
|
+
.mockResolvedValueOnce({
|
|
78
|
+
data: {
|
|
79
|
+
challengeId: "challenge_2",
|
|
80
|
+
message: "default key challenge",
|
|
81
|
+
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
82
|
+
},
|
|
83
|
+
status: 201,
|
|
84
|
+
})
|
|
85
|
+
.mockResolvedValueOnce({ data: { jwt: "default-key-jwt" }, status: 200 });
|
|
86
|
+
|
|
87
|
+
await expect(
|
|
88
|
+
exchangeAvailablePublicKeyJwt("org-1", "https://work.example", "/keys/stale")
|
|
89
|
+
).resolves.toBe("default-key-jwt");
|
|
90
|
+
|
|
91
|
+
expect(loadKeyPair.mock.calls).toEqual([
|
|
92
|
+
["/keys/stale"],
|
|
93
|
+
["/keys/default"],
|
|
94
|
+
]);
|
|
95
|
+
expect(signMessage).toHaveBeenCalledWith(
|
|
96
|
+
"default key challenge",
|
|
97
|
+
"/keys/default"
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
72
101
|
it("falls back when the key is unavailable without signing a challenge", async () => {
|
|
73
|
-
post.
|
|
102
|
+
post.mockRejectedValue({ status: 401 });
|
|
74
103
|
|
|
75
104
|
await expect(
|
|
76
105
|
authenticateWithKey("org-1", "https://work.example", "/keys/work")
|
|
77
106
|
).resolves.toBe(false);
|
|
78
107
|
|
|
79
|
-
expect(post).toHaveBeenCalledTimes(
|
|
108
|
+
expect(post).toHaveBeenCalledTimes(2);
|
|
80
109
|
expect(signMessage).not.toHaveBeenCalled();
|
|
81
110
|
});
|
|
82
111
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
|
|
3
|
+
const mockGet = jest.fn();
|
|
4
|
+
const mockExchangePublicKeyJwt = jest.fn();
|
|
5
|
+
const mockStoreJwt = jest.fn();
|
|
6
|
+
|
|
7
|
+
jest.mock("../../../src/utils/http", () => {
|
|
8
|
+
const actual = jest.requireActual("../../../src/utils/http");
|
|
9
|
+
const mockedHttp = {
|
|
10
|
+
get: (...args: unknown[]) => mockGet(...args),
|
|
11
|
+
};
|
|
12
|
+
return {
|
|
13
|
+
__esModule: true,
|
|
14
|
+
...actual,
|
|
15
|
+
http: mockedHttp,
|
|
16
|
+
default: mockedHttp,
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
jest.mock("../../../src/config", () => ({
|
|
21
|
+
getConfigSync: jest.fn().mockReturnValue({
|
|
22
|
+
orgId: "org-1",
|
|
23
|
+
cliIdentityPath: "/tmp/identity",
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
jest.mock("../../../src/auth/keyManager", () => ({
|
|
28
|
+
keyPairExists: jest.fn().mockReturnValue(true),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
jest.mock("../../../src/auth/keyAuth", () => ({
|
|
32
|
+
exchangeAvailablePublicKeyJwt: (...args: unknown[]) => mockExchangePublicKeyJwt(...args),
|
|
33
|
+
storeJwt: (...args: unknown[]) => mockStoreJwt(...args),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
import { KnowhowSimpleClient } from "../../../src/services/KnowhowClient";
|
|
37
|
+
|
|
38
|
+
describe("KnowhowSimpleClient identity renewal", () => {
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
jest.restoreAllMocks();
|
|
41
|
+
jest.clearAllMocks();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("obtains a JWT from the registered identity before loading models when no JWT exists", async () => {
|
|
45
|
+
jest.spyOn(fs, "existsSync").mockReturnValue(false);
|
|
46
|
+
mockExchangePublicKeyJwt.mockResolvedValue("fresh-jwt");
|
|
47
|
+
mockGet.mockResolvedValueOnce({ data: { data: [] } });
|
|
48
|
+
|
|
49
|
+
const client = new KnowhowSimpleClient("https://api.example.test");
|
|
50
|
+
await client.getModels();
|
|
51
|
+
|
|
52
|
+
expect(mockExchangePublicKeyJwt).toHaveBeenCalledWith(
|
|
53
|
+
"org-1",
|
|
54
|
+
"https://api.example.test",
|
|
55
|
+
"/tmp/identity"
|
|
56
|
+
);
|
|
57
|
+
expect(mockStoreJwt).toHaveBeenCalledWith("fresh-jwt");
|
|
58
|
+
expect(mockGet.mock.calls[0][1].headers.Authorization).toBe("Bearer fresh-jwt");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("replaces an expired JWT with one signed by the registered identity before loading models", async () => {
|
|
62
|
+
const expiredJwt = [
|
|
63
|
+
Buffer.from('{"alg":"none"}').toString("base64url"),
|
|
64
|
+
Buffer.from('{"exp":0}').toString("base64url"),
|
|
65
|
+
"old-signature",
|
|
66
|
+
].join(".");
|
|
67
|
+
jest.spyOn(fs, "existsSync").mockReturnValue(true);
|
|
68
|
+
jest.spyOn(fs, "readFileSync").mockReturnValue(expiredJwt);
|
|
69
|
+
mockExchangePublicKeyJwt.mockResolvedValue("fresh-jwt");
|
|
70
|
+
mockGet.mockResolvedValueOnce({ data: { data: [] } });
|
|
71
|
+
|
|
72
|
+
const client = new KnowhowSimpleClient("https://api.example.test");
|
|
73
|
+
await client.getModels();
|
|
74
|
+
|
|
75
|
+
expect(mockExchangePublicKeyJwt).toHaveBeenCalledWith(
|
|
76
|
+
"org-1",
|
|
77
|
+
"https://api.example.test",
|
|
78
|
+
"/tmp/identity"
|
|
79
|
+
);
|
|
80
|
+
expect(mockStoreJwt).toHaveBeenCalledWith("fresh-jwt");
|
|
81
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
82
|
+
expect(mockGet.mock.calls[0][1].headers.Authorization).toBe("Bearer fresh-jwt");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { FsSyncedAgentWatcher } from "../../../src/services/watchers/FsSyncer";
|
|
2
|
+
import { WebSyncedAgentWatcher } from "../../../src/services/watchers/RemoteSyncer";
|
|
3
|
+
|
|
4
|
+
const retained = { role: "assistant", content: "before compaction" };
|
|
5
|
+
const oldThread = [
|
|
6
|
+
{ role: "system", content: "instructions" },
|
|
7
|
+
{ role: "user", content: "task" },
|
|
8
|
+
retained,
|
|
9
|
+
];
|
|
10
|
+
const compactedThread = [
|
|
11
|
+
{ role: "system", content: "instructions" },
|
|
12
|
+
{ role: "user", content: "compressed summary" },
|
|
13
|
+
retained,
|
|
14
|
+
{ role: "assistant", content: "after compaction" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function seedCursor(watcher: any): void {
|
|
18
|
+
watcher.taskId = "task-1";
|
|
19
|
+
watcher.agentName = "Developer";
|
|
20
|
+
watcher.lastThreadIndex = 0;
|
|
21
|
+
watcher.lastThreadLength = oldThread.length;
|
|
22
|
+
watcher.lastMessageJson = JSON.stringify(retained);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("synced agent watcher thread rollover", () => {
|
|
26
|
+
it("continues rendering after filesystem agent compaction", async () => {
|
|
27
|
+
const watcher: any = new FsSyncedAgentWatcher();
|
|
28
|
+
seedCursor(watcher);
|
|
29
|
+
watcher.readMetadata = jest.fn().mockResolvedValue({
|
|
30
|
+
threads: [oldThread, compactedThread],
|
|
31
|
+
status: "running",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const messages: string[] = [];
|
|
35
|
+
watcher.agentEvents.on(watcher.eventTypes.agentSay, (data: any) => {
|
|
36
|
+
messages.push(data.message);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await watcher.onMetadataChanged();
|
|
40
|
+
|
|
41
|
+
expect(messages).toEqual(["after compaction"]);
|
|
42
|
+
expect(watcher.lastThreadIndex).toBe(1);
|
|
43
|
+
expect(watcher.lastThreadLength).toBe(compactedThread.length);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("continues rendering after web agent compaction", async () => {
|
|
47
|
+
const client = {
|
|
48
|
+
getTaskDetails: jest.fn().mockResolvedValue({
|
|
49
|
+
data: {
|
|
50
|
+
threads: [oldThread, compactedThread],
|
|
51
|
+
status: "running",
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
55
|
+
const watcher: any = new WebSyncedAgentWatcher(client as any);
|
|
56
|
+
seedCursor(watcher);
|
|
57
|
+
|
|
58
|
+
const messages: string[] = [];
|
|
59
|
+
watcher.agentEvents.on(watcher.eventTypes.agentSay, (data: any) => {
|
|
60
|
+
messages.push(data.message);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await watcher.onPoll();
|
|
64
|
+
|
|
65
|
+
expect(messages).toEqual(["after compaction"]);
|
|
66
|
+
expect(watcher.lastThreadIndex).toBe(1);
|
|
67
|
+
expect(watcher.lastThreadLength).toBe(compactedThread.length);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("resets a cursor when a thread shrinks without changing index", async () => {
|
|
71
|
+
const watcher: any = new FsSyncedAgentWatcher();
|
|
72
|
+
seedCursor(watcher);
|
|
73
|
+
watcher.lastThreadLength = 10;
|
|
74
|
+
watcher.readMetadata = jest.fn().mockResolvedValue({
|
|
75
|
+
threads: [compactedThread],
|
|
76
|
+
status: "running",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const messages: string[] = [];
|
|
80
|
+
watcher.agentEvents.on(watcher.eventTypes.agentSay, (data: any) => {
|
|
81
|
+
messages.push(data.message);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await watcher.onMetadataChanged();
|
|
85
|
+
|
|
86
|
+
expect(messages).toEqual(["after compaction"]);
|
|
87
|
+
expect(watcher.lastThreadLength).toBe(compactedThread.length);
|
|
88
|
+
});
|
|
89
|
+
});
|
package/ts_build/package.json
CHANGED
|
@@ -191,6 +191,7 @@ export declare abstract class BaseAgent implements IAgent {
|
|
|
191
191
|
role: string;
|
|
192
192
|
content: string;
|
|
193
193
|
} | null;
|
|
194
|
+
extractContentFromMessages(messages: Message[]): string;
|
|
194
195
|
getTaskBreakdown(messages: Message[]): Promise<string>;
|
|
195
196
|
compressMessages(messages: Message[]): Promise<Message[]>;
|
|
196
197
|
}
|
|
@@ -1017,6 +1017,17 @@ let BaseAgent = class BaseAgent {
|
|
|
1017
1017
|
}
|
|
1018
1018
|
return null;
|
|
1019
1019
|
}
|
|
1020
|
+
extractContentFromMessages(messages) {
|
|
1021
|
+
const content = messages
|
|
1022
|
+
.filter((m) => typeof m.content === "string" && m.content.trim())
|
|
1023
|
+
.map((m) => m.content);
|
|
1024
|
+
const finalAnswer = messages
|
|
1025
|
+
.filter((m) => m.tool_calls?.length)
|
|
1026
|
+
.flatMap((t) => t.tool_calls)
|
|
1027
|
+
.filter((t) => t.function.name === "finalAnswer")
|
|
1028
|
+
.map((t) => t.function.arguments);
|
|
1029
|
+
return [...content, ...finalAnswer].join("\n\n");
|
|
1030
|
+
}
|
|
1020
1031
|
async getTaskBreakdown(messages) {
|
|
1021
1032
|
if (this.taskBreakdown) {
|
|
1022
1033
|
return this.taskBreakdown;
|
|
@@ -1039,22 +1050,12 @@ let BaseAgent = class BaseAgent {
|
|
|
1039
1050
|
},
|
|
1040
1051
|
];
|
|
1041
1052
|
const response = await this.createAgentCompletion({
|
|
1042
|
-
model,
|
|
1043
1053
|
messages: taskBreakdownMessages,
|
|
1044
1054
|
tool_choice: "none",
|
|
1045
|
-
long_ttl_cache: this.runTime() > 300_000,
|
|
1046
|
-
...(this.reasoningEffort !== undefined && {
|
|
1047
|
-
reasoning_effort: this.reasoningEffort,
|
|
1048
|
-
}),
|
|
1049
|
-
...(this.summarizeReasoning !== undefined && {
|
|
1050
|
-
reasoning_summary: this.summarizeReasoning,
|
|
1051
|
-
}),
|
|
1052
1055
|
});
|
|
1053
|
-
const breakdownContent = response.choices
|
|
1054
|
-
.map((c) => c.message.content)
|
|
1055
|
-
.filter((s) => typeof s === "string" && s.trim().length > 0)
|
|
1056
|
-
.join("\n\n");
|
|
1056
|
+
const breakdownContent = this.extractContentFromMessages(response.choices?.map((c) => c.message));
|
|
1057
1057
|
if (!breakdownContent) {
|
|
1058
|
+
console.log(JSON.stringify(response.choices, null, 2));
|
|
1058
1059
|
throw new Error("Compaction task-breakdown request returned no text content");
|
|
1059
1060
|
}
|
|
1060
1061
|
this.taskBreakdown = breakdownContent;
|
|
@@ -1063,8 +1064,7 @@ let BaseAgent = class BaseAgent {
|
|
|
1063
1064
|
}
|
|
1064
1065
|
async compressMessages(messages) {
|
|
1065
1066
|
let resumeIndex = messages.length - 1;
|
|
1066
|
-
while (resumeIndex >= 0 &&
|
|
1067
|
-
messages[resumeIndex].role !== "assistant") {
|
|
1067
|
+
while (resumeIndex >= 0 && messages[resumeIndex].role !== "assistant") {
|
|
1068
1068
|
resumeIndex--;
|
|
1069
1069
|
}
|
|
1070
1070
|
const compressionEnd = resumeIndex === -1 ? messages.length : resumeIndex;
|
|
@@ -1080,7 +1080,9 @@ let BaseAgent = class BaseAgent {
|
|
|
1080
1080
|
3. Next Steps - what we're about to do next to continue the user's original request.
|
|
1081
1081
|
4. Tasks remaining - what tasks are left from the initial task breakdown.
|
|
1082
1082
|
|
|
1083
|
-
${this.taskBreakdown
|
|
1083
|
+
${this.taskBreakdown
|
|
1084
|
+
? `Our initial task breakdown: ${this.taskBreakdown}`
|
|
1085
|
+
: ""}
|
|
1084
1086
|
|
|
1085
1087
|
This summary will replace the older history. The latest agent interaction will remain verbatim:
|
|
1086
1088
|
|
|
@@ -1096,21 +1098,24 @@ let BaseAgent = class BaseAgent {
|
|
|
1096
1098
|
messages: compressMessagesPayload,
|
|
1097
1099
|
tool_choice: "none",
|
|
1098
1100
|
});
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1101
|
-
.
|
|
1102
|
-
if (summaries.length === 0) {
|
|
1101
|
+
const summary = this.extractContentFromMessages(response.choices?.map((c) => c.message));
|
|
1102
|
+
if (summary.length === 0) {
|
|
1103
|
+
console.log(JSON.stringify(response.choices, null, 2));
|
|
1103
1104
|
throw new Error("Compaction summary request returned no text content");
|
|
1104
1105
|
}
|
|
1105
|
-
this.summaries.push(
|
|
1106
|
+
this.summaries.push(summary);
|
|
1106
1107
|
const startMessages = [
|
|
1107
1108
|
{
|
|
1108
1109
|
role: "user",
|
|
1109
1110
|
content: `
|
|
1110
|
-
${this.taskBreakdown
|
|
1111
|
+
${this.taskBreakdown
|
|
1112
|
+
? `Initial task breakdown:\n ${this.taskBreakdown}`
|
|
1113
|
+
: "(No task breakdown available — summarize what you know from context above)"}
|
|
1111
1114
|
|
|
1112
1115
|
We have just compressed the conversation to save memory:
|
|
1113
|
-
${
|
|
1116
|
+
${summary.length > 0
|
|
1117
|
+
? JSON.stringify(summary)
|
|
1118
|
+
: "(summary unavailable — please continue from the task breakdown above)"}
|
|
1114
1119
|
|
|
1115
1120
|
Please continue the task from where we left off
|
|
1116
1121
|
`,
|