@circuitwall/jarela 0.7.2 → 0.7.3
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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +2 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js +51 -35
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +2 -2
- package/.next/standalone/.next/server/app/index.html +2 -2
- package/.next/standalone/.next/server/app/index.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page.js +515 -104
- package/.next/standalone/.next/server/app/page.js.map +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup.html +1 -1
- package/.next/standalone/.next/server/app/setup.rsc +2 -2
- package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/setup.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/setup.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/setup.segments/setup.segment.rsc +1 -1
- package/.next/standalone/.next/server/chunks/1683.js +26 -16
- package/.next/standalone/.next/server/chunks/1683.js.map +1 -1
- package/.next/standalone/.next/server/chunks/{317.js → 5432.js} +11100 -10858
- package/.next/standalone/.next/server/chunks/5432.js.map +1 -0
- package/.next/standalone/.next/server/chunks/7885.js +606 -353
- package/.next/standalone/.next/server/chunks/7885.js.map +1 -1
- package/.next/standalone/.next/server/chunks/8135.js +59 -16
- package/.next/standalone/.next/server/chunks/8135.js.map +1 -1
- package/.next/standalone/.next/server/chunks/9032.js +3 -3
- package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
- package/.next/standalone/.next/server/instrumentation.js +3 -3
- package/.next/standalone/.next/server/instrumentation.js.map +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
- package/.next/standalone/.next/server/pages/404.html +2 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/app/{page-a20902703c0a4f10.js → page-9fb006074fb13526.js} +582 -171
- package/.next/standalone/.next/static/chunks/app/page-9fb006074fb13526.js.map +1 -0
- package/.next/standalone/.next/static/css/5507dbe1cdc6c599.css +5 -0
- package/.next/standalone/.next/static/css/5507dbe1cdc6c599.css.map +1 -0
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +83 -1
- package/api/types.ts +7 -0
- package/app/api/v1/agents/[id]/compact/route.ts +2 -40
- package/components/bridges/BridgeEditor.tsx +8 -0
- package/components/chat/MessageBubble.tsx +3 -36
- package/components/models/ModelEditor.tsx +141 -0
- package/components/scheduled-tasks/ScheduledTasksPanel.tsx +5 -0
- package/components/scheduled-tasks/WatchersSection.tsx +5 -0
- package/lib/agents/context-budget.test.ts +128 -0
- package/lib/agents/context-budget.ts +128 -0
- package/lib/agents/conversation-summary.test.ts +68 -0
- package/lib/agents/conversation-summary.ts +51 -0
- package/lib/agents/run-thread.ts +112 -2
- package/lib/bridges/dispatcher.test.ts +134 -0
- package/lib/bridges/dispatcher.ts +34 -16
- package/lib/bridges/message-role.test.ts +83 -0
- package/lib/bridges/message-role.ts +46 -0
- package/lib/triggers/handlers/watcher.test.ts +23 -4
- package/lib/triggers/handlers/watcher.ts +56 -8
- package/package.json +1 -1
- package/.next/standalone/.next/server/chunks/317.js.map +0 -1
- package/.next/standalone/.next/static/chunks/app/page-a20902703c0a4f10.js.map +0 -1
- package/.next/standalone/.next/static/css/cc66c456aba91258.css +0 -5
- package/.next/standalone/.next/static/css/cc66c456aba91258.css.map +0 -1
- /package/.next/standalone/.next/static/{IauO0rNZkUVPX834k-SBa → AbCOWpaxP4v4lUSeFWWYz}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{IauO0rNZkUVPX834k-SBa → AbCOWpaxP4v4lUSeFWWYz}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { MessageRow } from "@/lib/stores/threads";
|
|
2
|
+
import { transcriptText } from "@/lib/agents/conversation-summary";
|
|
3
|
+
|
|
4
|
+
export type ContextTier = "hot" | "warm" | "facts";
|
|
5
|
+
|
|
6
|
+
export type ContextTierPriority = [ContextTier, ContextTier, ContextTier];
|
|
7
|
+
|
|
8
|
+
export interface ContextTierProportions {
|
|
9
|
+
hot?: number;
|
|
10
|
+
warm?: number;
|
|
11
|
+
facts?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ContextBudgetConfig {
|
|
15
|
+
context_window_tokens?: number;
|
|
16
|
+
max_tokens?: number;
|
|
17
|
+
context_tier_proportions?: ContextTierProportions;
|
|
18
|
+
context_tier_priority?: ContextTierPriority | readonly ContextTier[] | unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ContextBudget {
|
|
22
|
+
contextWindowTokens: number;
|
|
23
|
+
outputReserveTokens: number;
|
|
24
|
+
inputBudgetTokens: number;
|
|
25
|
+
overheadTokens: number;
|
|
26
|
+
tierBudgets: Record<ContextTier, number>;
|
|
27
|
+
tierPriority: ContextTierPriority;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_CONTEXT_WINDOW_TOKENS = 8_192;
|
|
31
|
+
const DEFAULT_OVERHEAD_TOKENS = 1_200;
|
|
32
|
+
const DEFAULT_OUTPUT_RESERVE_RATIO = 0.2;
|
|
33
|
+
const DEFAULT_TIER_PRIORITY: ContextTierPriority = ["hot", "warm", "facts"];
|
|
34
|
+
const DEFAULT_TIER_PROPORTIONS: Required<ContextTierProportions> = {
|
|
35
|
+
hot: 0.6,
|
|
36
|
+
warm: 0.25,
|
|
37
|
+
facts: 0.15,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function estimateTokens(text: string): number {
|
|
41
|
+
const trimmed = text.trim();
|
|
42
|
+
if (!trimmed) return 0;
|
|
43
|
+
return Math.max(1, Math.ceil(trimmed.length / 4));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeTierPriority(value: ContextBudgetConfig["context_tier_priority"]): ContextTierPriority {
|
|
47
|
+
if (!Array.isArray(value)) return DEFAULT_TIER_PRIORITY;
|
|
48
|
+
const tiers = value.filter((v): v is ContextTier => v === "hot" || v === "warm" || v === "facts");
|
|
49
|
+
if (tiers.length !== 3) return DEFAULT_TIER_PRIORITY;
|
|
50
|
+
if (new Set(tiers).size !== 3) return DEFAULT_TIER_PRIORITY;
|
|
51
|
+
return [tiers[0], tiers[1], tiers[2]];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function normalizeTierProportions(value: ContextTierProportions | undefined): Required<ContextTierProportions> {
|
|
55
|
+
const hot = toPositiveNumber(value?.hot, DEFAULT_TIER_PROPORTIONS.hot);
|
|
56
|
+
const warm = toPositiveNumber(value?.warm, DEFAULT_TIER_PROPORTIONS.warm);
|
|
57
|
+
const facts = toPositiveNumber(value?.facts, DEFAULT_TIER_PROPORTIONS.facts);
|
|
58
|
+
const sum = hot + warm + facts;
|
|
59
|
+
if (sum <= 0) return DEFAULT_TIER_PROPORTIONS;
|
|
60
|
+
return {
|
|
61
|
+
hot: hot / sum,
|
|
62
|
+
warm: warm / sum,
|
|
63
|
+
facts: facts / sum,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function computeContextBudget(config: ContextBudgetConfig): ContextBudget {
|
|
68
|
+
const contextWindowTokens = Math.max(
|
|
69
|
+
1,
|
|
70
|
+
Math.floor(config.context_window_tokens ?? DEFAULT_CONTEXT_WINDOW_TOKENS),
|
|
71
|
+
);
|
|
72
|
+
const outputReserveTokens = Math.max(
|
|
73
|
+
256,
|
|
74
|
+
Math.min(
|
|
75
|
+
contextWindowTokens - 1,
|
|
76
|
+
Math.floor(config.max_tokens ?? contextWindowTokens * DEFAULT_OUTPUT_RESERVE_RATIO),
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
const overheadTokens = Math.max(0, Math.min(DEFAULT_OVERHEAD_TOKENS, contextWindowTokens - outputReserveTokens));
|
|
80
|
+
const inputBudgetTokens = Math.max(0, contextWindowTokens - outputReserveTokens - overheadTokens);
|
|
81
|
+
const proportions = normalizeTierProportions(config.context_tier_proportions);
|
|
82
|
+
const tierPriority = normalizeTierPriority(config.context_tier_priority);
|
|
83
|
+
const tierBudgets = {
|
|
84
|
+
hot: Math.floor(inputBudgetTokens * proportions.hot),
|
|
85
|
+
warm: Math.floor(inputBudgetTokens * proportions.warm),
|
|
86
|
+
facts: Math.max(0, inputBudgetTokens - Math.floor(inputBudgetTokens * proportions.hot) - Math.floor(inputBudgetTokens * proportions.warm)),
|
|
87
|
+
} satisfies Record<ContextTier, number>;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
contextWindowTokens,
|
|
91
|
+
outputReserveTokens,
|
|
92
|
+
inputBudgetTokens,
|
|
93
|
+
overheadTokens,
|
|
94
|
+
tierBudgets,
|
|
95
|
+
tierPriority,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function takeRecentMessagesWithinBudget(messages: readonly MessageRow[], tokenBudget: number): MessageRow[] {
|
|
100
|
+
if (tokenBudget <= 0 || messages.length === 0) return [];
|
|
101
|
+
const chosen: MessageRow[] = [];
|
|
102
|
+
let used = 0;
|
|
103
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
104
|
+
const msg = messages[i];
|
|
105
|
+
const tokens = estimateTokens(transcriptText(msg.content));
|
|
106
|
+
if (chosen.length > 0 && used + tokens > tokenBudget) break;
|
|
107
|
+
chosen.push(msg);
|
|
108
|
+
used += tokens;
|
|
109
|
+
if (used >= tokenBudget) break;
|
|
110
|
+
}
|
|
111
|
+
return chosen.reverse();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function formatContextBudgetSummary(budget: ContextBudget): string {
|
|
115
|
+
const parts = [
|
|
116
|
+
`window ${budget.contextWindowTokens} tokens`,
|
|
117
|
+
`output reserve ${budget.outputReserveTokens}`,
|
|
118
|
+
`input budget ${budget.inputBudgetTokens}`,
|
|
119
|
+
`hot ${budget.tierBudgets.hot}`,
|
|
120
|
+
`warm ${budget.tierBudgets.warm}`,
|
|
121
|
+
`facts ${budget.tierBudgets.facts}`,
|
|
122
|
+
];
|
|
123
|
+
return parts.join(" · ");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function toPositiveNumber(value: unknown, fallback: number): number {
|
|
127
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
128
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { summarizeTranscript, transcriptText } from "./conversation-summary";
|
|
3
|
+
import type { ModelProvider, ProviderMessage, ProviderParams } from "@/lib/providers/types";
|
|
4
|
+
|
|
5
|
+
describe("transcriptText", () => {
|
|
6
|
+
it("returns plain text unchanged", () => {
|
|
7
|
+
expect(transcriptText("hello")).toBe("hello");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("flattens content parts and stubs attachments", () => {
|
|
11
|
+
const raw = JSON.stringify([
|
|
12
|
+
{ type: "text", text: "hello" },
|
|
13
|
+
{ type: "image", media_type: "image/png", data: "a" },
|
|
14
|
+
{ type: "file", name: "report.pdf", media_type: "application/pdf", data: "b" },
|
|
15
|
+
]);
|
|
16
|
+
expect(transcriptText(raw)).toContain("hello");
|
|
17
|
+
expect(transcriptText(raw)).toContain("[image attachment: image/png]");
|
|
18
|
+
expect(transcriptText(raw)).toContain("[file attachment: report.pdf (application/pdf)]");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("falls back to raw when JSON is malformed", () => {
|
|
22
|
+
expect(transcriptText("[not-json")).toBe("[not-json");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("summarizeTranscript", () => {
|
|
27
|
+
it("returns empty for empty transcript", async () => {
|
|
28
|
+
const provider = {
|
|
29
|
+
chat: vi.fn(),
|
|
30
|
+
} as unknown as Pick<ModelProvider, "chat">;
|
|
31
|
+
const out = await summarizeTranscript(provider, "m", {}, " ");
|
|
32
|
+
expect(out).toBe("");
|
|
33
|
+
expect(provider.chat).not.toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("streams chunks and trims final summary", async () => {
|
|
37
|
+
const chat = vi.fn(async (_modelId: string, _messages: ProviderMessage[], _params: ProviderParams) => {
|
|
38
|
+
async function* gen() {
|
|
39
|
+
yield " first";
|
|
40
|
+
yield " second ";
|
|
41
|
+
}
|
|
42
|
+
return { stream: gen() };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const provider = { chat } as unknown as Pick<ModelProvider, "chat">;
|
|
46
|
+
const out = await summarizeTranscript(provider, "model-x", { max_tokens: 100 }, "conversation");
|
|
47
|
+
expect(out).toBe("first second");
|
|
48
|
+
expect(chat).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("sends summarizer system prompt and transcript payload", async () => {
|
|
52
|
+
const chat = vi.fn(async (_modelId: string, messages: ProviderMessage[], _params: ProviderParams) => {
|
|
53
|
+
expect(messages[0].role).toBe("system");
|
|
54
|
+
expect(String(messages[0].content)).toContain("concise summarizer");
|
|
55
|
+
expect(messages[1].role).toBe("user");
|
|
56
|
+
expect(String(messages[1].content)).toContain("Conversation to summarize");
|
|
57
|
+
expect(String(messages[1].content)).toContain("alpha beta");
|
|
58
|
+
async function* gen() {
|
|
59
|
+
yield "ok";
|
|
60
|
+
}
|
|
61
|
+
return { stream: gen() };
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const provider = { chat } as unknown as Pick<ModelProvider, "chat">;
|
|
65
|
+
const out = await summarizeTranscript(provider, "model-x", {}, "alpha beta");
|
|
66
|
+
expect(out).toBe("ok");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ContentPart } from "@/lib/tools/types";
|
|
2
|
+
import type { ModelProvider, ProviderMessage, ProviderParams } from "@/lib/providers/types";
|
|
3
|
+
|
|
4
|
+
export function transcriptText(raw: string): string {
|
|
5
|
+
if (!raw.startsWith("[")) return raw;
|
|
6
|
+
try {
|
|
7
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
8
|
+
if (!Array.isArray(parsed)) return raw;
|
|
9
|
+
return (parsed as ContentPart[])
|
|
10
|
+
.map((p) => {
|
|
11
|
+
if (p.type === "text") return p.text;
|
|
12
|
+
if (p.type === "image") return `[image attachment: ${p.media_type}]`;
|
|
13
|
+
if (p.type === "file") return `[file attachment: ${p.name} (${p.media_type})]`;
|
|
14
|
+
return "";
|
|
15
|
+
})
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.join(" ")
|
|
18
|
+
.trim();
|
|
19
|
+
} catch {
|
|
20
|
+
return raw;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function summaryMessages(transcript: string): ProviderMessage[] {
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
role: "system",
|
|
28
|
+
content:
|
|
29
|
+
"You are a concise summarizer. Summarize the conversation below in 3-7 bullet points, capturing key facts, decisions, and context that would be useful to remember later.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
role: "user",
|
|
33
|
+
content: `Conversation to summarize:\n\n${transcript}`,
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function summarizeTranscript(
|
|
39
|
+
provider: Pick<ModelProvider, "chat">,
|
|
40
|
+
modelId: string,
|
|
41
|
+
providerParams: ProviderParams,
|
|
42
|
+
transcript: string,
|
|
43
|
+
): Promise<string> {
|
|
44
|
+
const trimmed = transcript.trim();
|
|
45
|
+
if (!trimmed) return "";
|
|
46
|
+
|
|
47
|
+
const { stream } = await provider.chat(modelId, summaryMessages(trimmed), providerParams);
|
|
48
|
+
let summary = "";
|
|
49
|
+
for await (const chunk of stream) summary += chunk;
|
|
50
|
+
return summary.trim();
|
|
51
|
+
}
|
package/lib/agents/run-thread.ts
CHANGED
|
@@ -13,6 +13,12 @@ import { resolveHarness } from "@/lib/agents/harness/resolve";
|
|
|
13
13
|
import { validateAssistantOutput } from "@/lib/agents/output-validator";
|
|
14
14
|
import { getAppName } from "@/lib/env/app-config";
|
|
15
15
|
import os from "node:os";
|
|
16
|
+
import { computeContextBudget, formatContextBudgetSummary, takeRecentMessagesWithinBudget } from "@/lib/agents/context-budget";
|
|
17
|
+
import { listMemory } from "@/lib/stores/memory";
|
|
18
|
+
import { summarizeTranscript, transcriptText } from "@/lib/agents/conversation-summary";
|
|
19
|
+
import { getDefaultModelConfig, getModelConfig } from "@/lib/stores/model-config";
|
|
20
|
+
import { getProvider } from "@/lib/providers";
|
|
21
|
+
import type { ProviderParams } from "@/lib/providers/types";
|
|
16
22
|
|
|
17
23
|
// Resolve the app name once at module load. Forks set NEXT_PUBLIC_APP_NAME to
|
|
18
24
|
// rebrand the user-visible name the LLM echoes in chat replies; default
|
|
@@ -145,7 +151,35 @@ export async function prepareThreadRun(
|
|
|
145
151
|
const sinceISO = windowHours > 0
|
|
146
152
|
? new Date(Date.now() - windowHours * 3600_000).toISOString()
|
|
147
153
|
: undefined;
|
|
148
|
-
const
|
|
154
|
+
const allWindowMessages = getRecentMessagesWindow(thread_id, limit, sinceISO);
|
|
155
|
+
|
|
156
|
+
const modelCfg = agentCfg.model_config_name
|
|
157
|
+
? getModelConfig(agentCfg.model_config_name)
|
|
158
|
+
: getDefaultModelConfig();
|
|
159
|
+
let providerParams: ProviderParams = {};
|
|
160
|
+
if (modelCfg) {
|
|
161
|
+
try {
|
|
162
|
+
providerParams = JSON.parse(modelCfg.params) as ProviderParams;
|
|
163
|
+
} catch {
|
|
164
|
+
providerParams = {};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const budget = computeContextBudget({
|
|
169
|
+
context_window_tokens:
|
|
170
|
+
typeof providerParams.context_window_tokens === "number"
|
|
171
|
+
? providerParams.context_window_tokens
|
|
172
|
+
: undefined,
|
|
173
|
+
max_tokens: typeof providerParams.max_tokens === "number" ? providerParams.max_tokens : undefined,
|
|
174
|
+
context_tier_proportions:
|
|
175
|
+
typeof providerParams.context_tier_proportions === "object" && providerParams.context_tier_proportions
|
|
176
|
+
? (providerParams.context_tier_proportions as { hot?: number; warm?: number; facts?: number })
|
|
177
|
+
: undefined,
|
|
178
|
+
context_tier_priority: providerParams.context_tier_priority,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const hotMessages = takeRecentMessagesWithinBudget(allWindowMessages, budget.tierBudgets.hot);
|
|
182
|
+
const history = hotMessages.map((m) => ({
|
|
149
183
|
role: m.role as "user" | "assistant",
|
|
150
184
|
content: parseContent(m.content),
|
|
151
185
|
}));
|
|
@@ -253,12 +287,32 @@ export async function prepareThreadRun(
|
|
|
253
287
|
const memoryCtx = [
|
|
254
288
|
"--- Memory & recall ---",
|
|
255
289
|
"You have long-term memory across sessions and a fresh recall pass on every turn.",
|
|
256
|
-
`-
|
|
290
|
+
`- Hot conversation history is budgeted by model context size: ${formatContextBudgetSummary(budget)}.`,
|
|
257
291
|
"- A semantic search over all stored memory entries + past chat messages was run against the user's turn; matching items appear under \"Relevant context\" below.",
|
|
258
292
|
"- Use memory_write proactively when the user shares a fact, preference, or decision worth remembering. Use memory_read / memory_list to recall stored facts on demand.",
|
|
259
293
|
"- If you want detail from outside the recent window, the user can scroll up — but for facts you've stored explicitly, prefer recall over guessing.",
|
|
260
294
|
].join("\n");
|
|
261
295
|
|
|
296
|
+
const warmSummaryCtx = await buildWarmSummaryContext(
|
|
297
|
+
allWindowMessages,
|
|
298
|
+
hotMessages.length,
|
|
299
|
+
modelCfg?.provider,
|
|
300
|
+
modelCfg?.model_id,
|
|
301
|
+
providerParams,
|
|
302
|
+
budget.tierBudgets.warm,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const factsCtx = buildFactsContext(trimmed, budget.tierBudgets.facts);
|
|
306
|
+
|
|
307
|
+
const tierCtxByName = {
|
|
308
|
+
hot: "",
|
|
309
|
+
warm: warmSummaryCtx,
|
|
310
|
+
facts: factsCtx,
|
|
311
|
+
} as const;
|
|
312
|
+
const tierOrderCtx = budget.tierPriority
|
|
313
|
+
.map((tier) => tierCtxByName[tier])
|
|
314
|
+
.filter(Boolean);
|
|
315
|
+
|
|
262
316
|
// Semantic recall: pull in long-term memory + past messages relevant to this turn.
|
|
263
317
|
// Skip messages from the current thread that are already in the windowed history.
|
|
264
318
|
// Capped at RECALL_BUDGET_MS — if the embedding round-trip is slower than
|
|
@@ -287,6 +341,7 @@ export async function prepareThreadRun(
|
|
|
287
341
|
envCtx,
|
|
288
342
|
harnessParts.self_config,
|
|
289
343
|
memoryCtx,
|
|
344
|
+
...tierOrderCtx,
|
|
290
345
|
recallCtx,
|
|
291
346
|
].filter(Boolean);
|
|
292
347
|
let allowedTools: string[] = [];
|
|
@@ -310,6 +365,61 @@ export async function prepareThreadRun(
|
|
|
310
365
|
};
|
|
311
366
|
}
|
|
312
367
|
|
|
368
|
+
async function buildWarmSummaryContext(
|
|
369
|
+
allWindowMessages: readonly { role: string; content: string }[],
|
|
370
|
+
hotCount: number,
|
|
371
|
+
providerName: string | undefined,
|
|
372
|
+
modelId: string | undefined,
|
|
373
|
+
providerParams: ProviderParams,
|
|
374
|
+
warmBudgetTokens: number,
|
|
375
|
+
): Promise<string> {
|
|
376
|
+
if (warmBudgetTokens <= 32) return "";
|
|
377
|
+
if (!providerName || !modelId) return "";
|
|
378
|
+
const warmMessages = allWindowMessages.slice(0, Math.max(0, allWindowMessages.length - hotCount));
|
|
379
|
+
if (warmMessages.length < 2) return "";
|
|
380
|
+
|
|
381
|
+
// Keep summary input bounded by the warm budget to avoid recursive prompt bloat.
|
|
382
|
+
const summaryInputChars = Math.max(0, warmBudgetTokens * 4);
|
|
383
|
+
const transcript = warmMessages
|
|
384
|
+
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${transcriptText(m.content)}`)
|
|
385
|
+
.join("\n\n")
|
|
386
|
+
.slice(-summaryInputChars);
|
|
387
|
+
if (!transcript.trim()) return "";
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
const provider = getProvider(providerName);
|
|
391
|
+
const summary = await summarizeTranscript(provider, modelId, providerParams, transcript);
|
|
392
|
+
if (!summary) return "";
|
|
393
|
+
return [
|
|
394
|
+
"--- Warm context summary ---",
|
|
395
|
+
"Compressed recap of earlier messages outside the hot window:",
|
|
396
|
+
summary,
|
|
397
|
+
].join("\n");
|
|
398
|
+
} catch {
|
|
399
|
+
return "";
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function buildFactsContext(query: string, factsBudgetTokens: number): string {
|
|
404
|
+
if (factsBudgetTokens <= 16) return "";
|
|
405
|
+
const charBudget = factsBudgetTokens * 4;
|
|
406
|
+
const rows = listMemory("facts", query.slice(0, 120), 12);
|
|
407
|
+
if (rows.length === 0) return "";
|
|
408
|
+
|
|
409
|
+
const lines = [
|
|
410
|
+
"--- Facts memory ---",
|
|
411
|
+
"Durable fact entries from memory_store namespace=facts:",
|
|
412
|
+
];
|
|
413
|
+
let used = 0;
|
|
414
|
+
for (const row of rows) {
|
|
415
|
+
const line = `- ${row.key}: ${String(row.value).slice(0, 220)}`;
|
|
416
|
+
if (used > 0 && used + line.length > charBudget) break;
|
|
417
|
+
lines.push(line);
|
|
418
|
+
used += line.length;
|
|
419
|
+
}
|
|
420
|
+
return lines.length > 2 ? lines.join("\n") : "";
|
|
421
|
+
}
|
|
422
|
+
|
|
313
423
|
// Wraps the raw agent stream with stall-retry logic. Chunks pass through
|
|
314
424
|
// LIVE to the consumer (so the chat UI sees deltas as they arrive); we only
|
|
315
425
|
// hold the terminal `done` chunk so we can decide whether to retry. If the
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { BridgeAdapter, InboundMessage } from "./types";
|
|
3
|
+
|
|
4
|
+
const resolveRouteMock = vi.fn();
|
|
5
|
+
const getAgentConfigMock = vi.fn();
|
|
6
|
+
const getOrCreateAgentThreadMock = vi.fn();
|
|
7
|
+
const prepareThreadRunMock = vi.fn();
|
|
8
|
+
const collectStreamMock = vi.fn();
|
|
9
|
+
const persistAssistantMessageMock = vi.fn();
|
|
10
|
+
const publishNotificationMock = vi.fn();
|
|
11
|
+
const formatBridgePromptMock = vi.fn();
|
|
12
|
+
|
|
13
|
+
vi.mock("./router", () => ({
|
|
14
|
+
resolveRoute: (...args: unknown[]) => resolveRouteMock(...args),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("@/lib/stores/agent-configs", () => ({
|
|
18
|
+
getAgentConfig: (...args: unknown[]) => getAgentConfigMock(...args),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("@/lib/stores/threads", () => ({
|
|
22
|
+
getOrCreateAgentThread: (...args: unknown[]) => getOrCreateAgentThreadMock(...args),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock("@/lib/agents/run-thread", () => ({
|
|
26
|
+
prepareThreadRun: (...args: unknown[]) => prepareThreadRunMock(...args),
|
|
27
|
+
persistAssistantMessage: (...args: unknown[]) => persistAssistantMessageMock(...args),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock("@/lib/agents/stream-collector", () => ({
|
|
31
|
+
collectStream: (...args: unknown[]) => collectStreamMock(...args),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock("@/lib/notifications/bus", () => ({
|
|
35
|
+
publish: (...args: unknown[]) => publishNotificationMock(...args),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("./message-role", () => ({
|
|
39
|
+
formatBridgePrompt: (...args: unknown[]) => formatBridgePromptMock(...args),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const { handleInboundMessage } = await import("./dispatcher");
|
|
43
|
+
|
|
44
|
+
function makeAdapter(): BridgeAdapter {
|
|
45
|
+
return {
|
|
46
|
+
bridge_id: "b1",
|
|
47
|
+
start: vi.fn(async () => {}),
|
|
48
|
+
stop: vi.fn(async () => {}),
|
|
49
|
+
sendText: vi.fn(async () => {}),
|
|
50
|
+
sendTyping: vi.fn(async () => {}),
|
|
51
|
+
resetAuth: vi.fn(async () => {}),
|
|
52
|
+
onInboundMessage: vi.fn(() => {}),
|
|
53
|
+
onStatusChange: vi.fn(() => {}),
|
|
54
|
+
listChats: vi.fn(() => []),
|
|
55
|
+
refreshChats: vi.fn(async () => {}),
|
|
56
|
+
lookupChat: vi.fn(async () => null),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeMessage(): InboundMessage {
|
|
61
|
+
return {
|
|
62
|
+
remote_jid: "chat@jid",
|
|
63
|
+
push_name: "Alice",
|
|
64
|
+
chat_name: "Family",
|
|
65
|
+
sender_name: "Bob",
|
|
66
|
+
text: "hello",
|
|
67
|
+
attachments: undefined,
|
|
68
|
+
message_id: "m1",
|
|
69
|
+
is_group: true,
|
|
70
|
+
participant_jid: "bob@jid",
|
|
71
|
+
role: "counterpart",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("handleInboundMessage silent observer mode", () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
resolveRouteMock.mockReset();
|
|
78
|
+
getAgentConfigMock.mockReset();
|
|
79
|
+
getOrCreateAgentThreadMock.mockReset();
|
|
80
|
+
prepareThreadRunMock.mockReset();
|
|
81
|
+
collectStreamMock.mockReset();
|
|
82
|
+
persistAssistantMessageMock.mockReset();
|
|
83
|
+
publishNotificationMock.mockReset();
|
|
84
|
+
formatBridgePromptMock.mockReset();
|
|
85
|
+
|
|
86
|
+
resolveRouteMock.mockReturnValue({
|
|
87
|
+
bridge_id: "b1",
|
|
88
|
+
remote_jid: "chat@jid",
|
|
89
|
+
agent_id: "a1",
|
|
90
|
+
silent_mode: 1,
|
|
91
|
+
respond_to: "counterpart",
|
|
92
|
+
});
|
|
93
|
+
getAgentConfigMock.mockReturnValue({ id: "a1" });
|
|
94
|
+
getOrCreateAgentThreadMock.mockReturnValue({ thread_id: "t1" });
|
|
95
|
+
prepareThreadRunMock.mockResolvedValue({ stream: {} });
|
|
96
|
+
formatBridgePromptMock.mockReturnValue("BRIDGE_PROMPT");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("suppresses non-important NO_REPLY assistant output", async () => {
|
|
100
|
+
const adapter = makeAdapter();
|
|
101
|
+
const msg = makeMessage();
|
|
102
|
+
collectStreamMock.mockResolvedValue({ assistantContent: "NO_REPLY", usedTools: [], toolEvents: [] });
|
|
103
|
+
|
|
104
|
+
await handleInboundMessage(adapter, msg);
|
|
105
|
+
|
|
106
|
+
expect(prepareThreadRunMock).toHaveBeenCalled();
|
|
107
|
+
const promptArg = prepareThreadRunMock.mock.calls[0][1] as string;
|
|
108
|
+
expect(promptArg).toContain("BRIDGE_PROMPT");
|
|
109
|
+
expect(promptArg).toContain("[SILENT_BRIDGE]");
|
|
110
|
+
expect(promptArg).toContain("standing on the user's side");
|
|
111
|
+
|
|
112
|
+
expect(persistAssistantMessageMock).not.toHaveBeenCalled();
|
|
113
|
+
expect(adapter.sendText).not.toHaveBeenCalled();
|
|
114
|
+
expect(publishNotificationMock).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("keeps important in-app update while still suppressing outbound chat replies", async () => {
|
|
118
|
+
const adapter = makeAdapter();
|
|
119
|
+
const msg = makeMessage();
|
|
120
|
+
collectStreamMock.mockResolvedValue({
|
|
121
|
+
assistantContent: "Important: the group announced an urgent schedule change.",
|
|
122
|
+
usedTools: [],
|
|
123
|
+
toolEvents: [],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await handleInboundMessage(adapter, msg);
|
|
127
|
+
|
|
128
|
+
expect(persistAssistantMessageMock).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(adapter.sendText).not.toHaveBeenCalled();
|
|
130
|
+
expect(publishNotificationMock).toHaveBeenCalledTimes(1);
|
|
131
|
+
const payload = publishNotificationMock.mock.calls[0][0] as { preview: string };
|
|
132
|
+
expect(payload.preview).toContain("Important:");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -7,6 +7,17 @@ import { resolveRoute } from "./router";
|
|
|
7
7
|
import { formatBridgePrompt } from "./message-role";
|
|
8
8
|
import type { BridgeAdapter, InboundMessage } from "./types";
|
|
9
9
|
|
|
10
|
+
const SILENT_BRIDGE_DIRECTIVE =
|
|
11
|
+
"[SILENT_BRIDGE] Observer mode is enabled for this route. You are standing on the user's side and monitoring events. " +
|
|
12
|
+
"Never behave like a participant in the external chat and never draft/imitate a direct chat reply. " +
|
|
13
|
+
"Write to the user only, as a concise report of important events, risks, or user-actionable changes. " +
|
|
14
|
+
"Keep tone informational (status/update summary), not conversational. " +
|
|
15
|
+
"If nothing important happened, reply with exactly the single token NO_REPLY and nothing else.";
|
|
16
|
+
|
|
17
|
+
function isNoReply(text: string): boolean {
|
|
18
|
+
return /^\s*NO[_ ]?REPLY\b/i.test(text);
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
/**
|
|
11
22
|
* Handle one inbound message from a bridge adapter:
|
|
12
23
|
* 1. Resolve the chat → agent route. Unrouted → publish an advisory
|
|
@@ -63,9 +74,13 @@ export async function handleInboundMessage(
|
|
|
63
74
|
sender_name: senderName,
|
|
64
75
|
text: msg.text,
|
|
65
76
|
});
|
|
77
|
+
const silent = route.silent_mode === 1;
|
|
78
|
+
const effectivePrompt = silent
|
|
79
|
+
? `${promptText}\n\n${SILENT_BRIDGE_DIRECTIVE}`
|
|
80
|
+
: promptText;
|
|
66
81
|
const prepared = await prepareThreadRun(
|
|
67
82
|
thread.thread_id,
|
|
68
|
-
|
|
83
|
+
effectivePrompt,
|
|
69
84
|
undefined,
|
|
70
85
|
msg.attachments,
|
|
71
86
|
undefined,
|
|
@@ -88,8 +103,6 @@ export async function handleInboundMessage(
|
|
|
88
103
|
// matches. Default 'counterpart' = agent answers the user's chat
|
|
89
104
|
// partner / group members but stays quiet on the user's own messages.
|
|
90
105
|
// 'user' = inverse — react only to what the paired user typed.
|
|
91
|
-
const silent = route.silent_mode === 1;
|
|
92
|
-
|
|
93
106
|
// Show the "composing…" presence on the channel while we drain the
|
|
94
107
|
// LLM stream. Refresh every ~8s because WhatsApp drops the indicator
|
|
95
108
|
// after ~10s if not renewed. We always send a final "paused" in the
|
|
@@ -126,9 +139,12 @@ export async function handleInboundMessage(
|
|
|
126
139
|
}
|
|
127
140
|
}
|
|
128
141
|
|
|
129
|
-
persistAssistantMessage(thread.thread_id, assistantContent, usedTools, toolEvents, "bridge");
|
|
130
|
-
|
|
131
142
|
const reply = assistantContent.trim();
|
|
143
|
+
const suppressAssistant = silent && (reply.length === 0 || isNoReply(reply));
|
|
144
|
+
if (!suppressAssistant) {
|
|
145
|
+
persistAssistantMessage(thread.thread_id, assistantContent, usedTools, toolEvents, "bridge");
|
|
146
|
+
}
|
|
147
|
+
|
|
132
148
|
// Outbound reply gate: silent_mode (master switch) AND respond_to
|
|
133
149
|
// (per-role trigger). Both must clear for a message to leave the
|
|
134
150
|
// dispatcher. The WhatsApp adapter also re-checks `route.silent_mode`
|
|
@@ -143,17 +159,19 @@ export async function handleInboundMessage(
|
|
|
143
159
|
}
|
|
144
160
|
}
|
|
145
161
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
162
|
+
if (!silent || !suppressAssistant) {
|
|
163
|
+
publishNotification({
|
|
164
|
+
type: "bridge_message_received",
|
|
165
|
+
bridge_id: adapter.bridge_id,
|
|
166
|
+
remote_jid: msg.remote_jid,
|
|
167
|
+
push_name: msg.push_name,
|
|
168
|
+
is_group: msg.is_group,
|
|
169
|
+
thread_id: thread.thread_id,
|
|
170
|
+
agent_id: agentId,
|
|
171
|
+
preview: suppressAssistant ? "" : reply.replace(/\s+/g, " ").slice(0, 120),
|
|
172
|
+
ts: Date.now(),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
157
175
|
} catch (err) {
|
|
158
176
|
const m = err instanceof Error ? err.message : String(err);
|
|
159
177
|
console.error(`[bridge ${adapter.bridge_id}] dispatcher error on ${msg.remote_jid}:`, m);
|