@animalabs/connectome-host 0.4.0 → 0.5.1
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/CHANGELOG.md +63 -0
- package/bun.lock +6 -6
- package/package.json +1 -1
- package/src/index.ts +17 -5
- package/src/logging-provider-wrapper.ts +165 -0
- package/src/modules/web-ui-module.ts +280 -1
- package/src/web/protocol.ts +130 -0
- package/test/settings-protocol.test.ts +64 -0
- package/web/src/App.tsx +35 -2
- package/web/src/Settings.tsx +434 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.5.1
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **llm-calls logging for every provider**: `LoggingProviderAdapter`, a
|
|
10
|
+
provider-agnostic decorator over any `ProviderAdapter`, wraps the
|
|
11
|
+
openai-codex, openrouter, and openai-responses transports — which
|
|
12
|
+
previously had NO wire visibility (found post-deploy on Mica: zero
|
|
13
|
+
llm-calls files, requests undiagnosable). Full raw request + response
|
|
14
|
+
summary + usage + timing + error per call, size-guarded against
|
|
15
|
+
pathological payloads. Anthropic/Bedrock keep their purpose-built
|
|
16
|
+
logging classes.
|
|
17
|
+
|
|
18
|
+
## 0.5.0 — 2026-07-26
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Context settings panel** (webui `Settings` tab) — live control of the
|
|
23
|
+
agent's compile window, replacing the stop → edit the `framework/state`
|
|
24
|
+
Chronicle slot → start dance. Edits `contextBudgetTokens`, `tailTokens` and
|
|
25
|
+
`transitionPaceTokens`; Apply / reset-to-recipe / revert-edits, plus cancel
|
|
26
|
+
for an in-flight descent.
|
|
27
|
+
- New client messages `request-settings`, `settings-update`,
|
|
28
|
+
`settings-reset`, `settings-cancel-transition`; new server frame
|
|
29
|
+
`settings-state`. No protocol version bump (additive).
|
|
30
|
+
- `settings-state` is **broadcast** to every welcomed client, unlike
|
|
31
|
+
`mcpl-list` — these are live process values, so two operators must not see
|
|
32
|
+
divergent budgets.
|
|
33
|
+
- Mutations are full-auth only for free: `observerMaySend` denies by default,
|
|
34
|
+
so new message types are never reachable by scoped observers.
|
|
35
|
+
- `persist: false` applies ephemerally (live now, reverts on restart) for
|
|
36
|
+
operator experiments. `notify: true` optionally pushes a notice to the
|
|
37
|
+
agent; **off by default**, because the notice is new text in the very
|
|
38
|
+
context being tuned — it invalidates the KV prefix and is itself
|
|
39
|
+
classifier-visible. The agent can always pull current settings via its own
|
|
40
|
+
`agent_settings` tool instead.
|
|
41
|
+
- The panel is explicit about three things that would otherwise mislead:
|
|
42
|
+
raising the budget applies at once but **lowering starts a paced
|
|
43
|
+
convergence** (shown as `converging` / `blocked`, with the blocked reason
|
|
44
|
+
spelled out); only a few keys are hot, so `targetChunkTokens`,
|
|
45
|
+
`headWindowTokens`, `mergeThreshold`, `foldingStrategy` and friends are
|
|
46
|
+
listed under "restart only" rather than offered as controls; and preview
|
|
47
|
+
requires a context-manager with dry-run support, so an older build reports
|
|
48
|
+
"preview unavailable" instead of rendering an empty result.
|
|
49
|
+
- **`GET /debug/context/preview?budget=&tail=[&agent=]`** — non-committing
|
|
50
|
+
preview of the fold plan at a hypothetical window. Persists no fold
|
|
51
|
+
resolutions, enqueues no compression, advances no transition bookkeeping
|
|
52
|
+
(the guarantee lives in context-manager's dry-run select). An infeasible
|
|
53
|
+
budget is reported as `fits: false` with per-component diagnostics rather
|
|
54
|
+
than an error — learning a budget cannot work is the reason to preview
|
|
55
|
+
instead of applying and taking the outage. Returns 501 when the resolved
|
|
56
|
+
context-manager predates dry-run support. Requires the `debug` scope.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- `/debug/context/curve` compiled against `app.recipe.agent.contextBudgetTokens`
|
|
61
|
+
— the **stale recipe** value. Runtime overrides live in the `framework/state`
|
|
62
|
+
Chronicle slot and win over the recipe, so the curve was plotted at the wrong
|
|
63
|
+
budget for any agent whose budget had ever been changed at runtime. Now reads
|
|
64
|
+
the live `getAgentRuntimeSettings`, falling back to the recipe.
|
|
65
|
+
|
|
3
66
|
## 0.4.0
|
|
4
67
|
|
|
5
68
|
### Changed
|
package/bun.lock
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
"": {
|
|
6
6
|
"name": "connectome-host",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@animalabs/agent-framework": "^0.
|
|
8
|
+
"@animalabs/agent-framework": "^0.7.0",
|
|
9
9
|
"@animalabs/chronicle": "^0.2.0",
|
|
10
|
-
"@animalabs/context-manager": "^0.
|
|
11
|
-
"@animalabs/membrane": "^0.5.
|
|
10
|
+
"@animalabs/context-manager": "^0.6.0",
|
|
11
|
+
"@animalabs/membrane": "^0.5.75",
|
|
12
12
|
"@opentui/core": "^0.1.82",
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
},
|
|
20
20
|
},
|
|
21
21
|
"packages": {
|
|
22
|
-
"@animalabs/agent-framework": ["@animalabs/agent-framework@0.
|
|
22
|
+
"@animalabs/agent-framework": ["@animalabs/agent-framework@0.7.0", "", { "dependencies": { "@animalabs/chronicle": "^0.2.2", "@animalabs/context-manager": "^0.6.0", "@animalabs/membrane": "^0.5.75", "chokidar": "^4.0.3", "discord.js": "^14.25.1", "ws": "^8.18.0" }, "bin": { "agent-framework-mcp": "dist/src/api/mcp-server.js", "agent-framework-recover": "dist/src/recovery/recover-cli.js" } }, "sha512-9FRQsCPaCZuZe0pyj+xq2NvdbSWQxRvdgO8nojVezpysaJL+w/Ba9tahGaOK7ZP12ZqnX3Q88wlttsXY+A47Gw=="],
|
|
23
23
|
|
|
24
24
|
"@animalabs/chronicle": ["@animalabs/chronicle@0.2.1", "", { "optionalDependencies": { "@animalabs/chronicle-darwin-arm64": "0.2.1", "@animalabs/chronicle-darwin-x64": "0.2.1", "@animalabs/chronicle-linux-arm64-gnu": "0.2.1", "@animalabs/chronicle-linux-x64-gnu": "0.2.1", "@animalabs/chronicle-win32-x64-msvc": "0.2.1" } }, "sha512-rDFc069yfi7BQUusgsXBwBdqljUWLByXSYaTF40AP+tonBh0No3eF9VzKTSy1I8DYg/jAUHIKtVdKDGsVMVubw=="],
|
|
25
25
|
|
|
26
|
-
"@animalabs/context-manager": ["@animalabs/context-manager@0.
|
|
26
|
+
"@animalabs/context-manager": ["@animalabs/context-manager@0.6.0", "", { "dependencies": { "@animalabs/chronicle": "^0.2.6", "@animalabs/membrane": "^0.5.69" } }, "sha512-X8PKvHouS0dXt6faHeMyI2FehgyFKPT3vBaqM7QN6o/O/3I5pU8UX+WR1nsixfgw+la83lDC+OHn+lPe1A7DVg=="],
|
|
27
27
|
|
|
28
|
-
"@animalabs/membrane": ["@animalabs/membrane@0.5.
|
|
28
|
+
"@animalabs/membrane": ["@animalabs/membrane@0.5.75", "", { "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } }, "sha512-eWtkrbQQga8UWDMH4VzPfZzcwYQLwngUcQ2tMeuERMBJ6WkXQzSJkcQVjM3ZvexvytnLsFTMOATvT58Igl9JuA=="],
|
|
29
29
|
|
|
30
30
|
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.52.0", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ=="],
|
|
31
31
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
OpenRouterAdapter,
|
|
26
26
|
} from '@animalabs/membrane';
|
|
27
27
|
import { LoggingAnthropicAdapter } from './logging-adapter.js';
|
|
28
|
+
import { LoggingProviderAdapter } from './logging-provider-wrapper.js';
|
|
28
29
|
import { LoggingBedrockAdapter } from './logging-bedrock-adapter.js';
|
|
29
30
|
import { CodexSubscriptionAdapter } from './codex-subscription-adapter.js';
|
|
30
31
|
import { CallLedger } from './call-ledger.js';
|
|
@@ -835,11 +836,22 @@ async function main() {
|
|
|
835
836
|
? new LoggingBedrockAdapter({}, llmLogPath)
|
|
836
837
|
: undefined;
|
|
837
838
|
const adapter = provider === 'openai-responses'
|
|
838
|
-
? new
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
839
|
+
? new LoggingProviderAdapter(
|
|
840
|
+
new OpenAIResponsesAPIAdapter({
|
|
841
|
+
apiKey: config.openaiApiKey!,
|
|
842
|
+
baseURL: process.env.OPENAI_BASE_URL || undefined,
|
|
843
|
+
}),
|
|
844
|
+
llmLogPath,
|
|
845
|
+
)
|
|
846
|
+
// Codex/openrouter/openai adapters get the provider-agnostic logging
|
|
847
|
+
// decorator (llm-calls.jsonl); anthropic and bedrock keep their
|
|
848
|
+
// purpose-built logging classes below. The bare `codexAdapter` /
|
|
849
|
+
// `openrouterAdapter` instances stay un-wrapped for auth commands and
|
|
850
|
+
// dispose() — only the membrane sees the wrapper.
|
|
851
|
+
: bedrockAdapter
|
|
852
|
+
?? (openrouterAdapter ? new LoggingProviderAdapter(openrouterAdapter, llmLogPath) : undefined)
|
|
853
|
+
?? (codexAdapter ? new LoggingProviderAdapter(codexAdapter, llmLogPath) : undefined)
|
|
854
|
+
?? new LoggingAnthropicAdapter(
|
|
843
855
|
{
|
|
844
856
|
// Anthropic OAuth wins over API-key auth when both are present.
|
|
845
857
|
// Subscription tokens additionally require this beta header.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Provider-agnostic ProviderAdapter decorator that appends each LLM call's
|
|
2
|
+
// request, response, usage, timing, and any error to the same
|
|
3
|
+
// `llm-calls.<iso>.jsonl` file the Anthropic and Bedrock paths use.
|
|
4
|
+
//
|
|
5
|
+
// Exists because llm-call logging grew adapter-by-adapter (Anthropic subclass,
|
|
6
|
+
// Bedrock wrapper) and the remaining providers — openai-codex (Mica),
|
|
7
|
+
// openrouter (K3) — had NO wire visibility at all: a post-deploy "did her
|
|
8
|
+
// requests actually work?" check on Mica (2026-07-26) found zero llm-calls
|
|
9
|
+
// files because the codex path never logged. This decorator wraps ANY
|
|
10
|
+
// ProviderAdapter, so a new provider gets logging by construction rather than
|
|
11
|
+
// by remembering to build a logging twin.
|
|
12
|
+
//
|
|
13
|
+
// Contents: full raw request and a summarized-but-complete response record
|
|
14
|
+
// (stop reason, usage, per-block shape, full text) on every call, plus the
|
|
15
|
+
// raw provider response. Retention/rotation/S3 shipping is the host box's
|
|
16
|
+
// llm-logs sync job's problem — this file just writes lines.
|
|
17
|
+
//
|
|
18
|
+
// OOM note (learned on the Anthropic path, which once logged raw+normalized+
|
|
19
|
+
// summary and contributed to production OOMs): we serialize the record ONCE,
|
|
20
|
+
// and a size guard drops the raw bodies for pathological payloads (giant
|
|
21
|
+
// image batches) rather than buffering multi-hundred-MB lines.
|
|
22
|
+
//
|
|
23
|
+
// Decorator safety: adapters whose complete() internally delegates to their
|
|
24
|
+
// own stream() (codex does) self-call the INNER method, not the wrapper —
|
|
25
|
+
// each membrane-level call logs exactly once.
|
|
26
|
+
//
|
|
27
|
+
// Each line: { type: 'call'|'error', provider, kind: 'complete'|'stream',
|
|
28
|
+
// timestamp, durationMs, requestSummary, response?, rawRequest?,
|
|
29
|
+
// rawResponse?, error?, truncated? }
|
|
30
|
+
|
|
31
|
+
import type {
|
|
32
|
+
ProviderAdapter,
|
|
33
|
+
ProviderRequest,
|
|
34
|
+
ProviderResponse,
|
|
35
|
+
ProviderRequestOptions,
|
|
36
|
+
StreamCallbacks,
|
|
37
|
+
} from '@animalabs/membrane';
|
|
38
|
+
import { appendFileSync } from 'node:fs';
|
|
39
|
+
|
|
40
|
+
/** Above this many serialized bytes, drop raw bodies and keep summaries. */
|
|
41
|
+
const MAX_RECORD_BYTES = 16 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
function summarizeRequest(request: ProviderRequest): Record<string, unknown> {
|
|
44
|
+
const msgs = (request.messages ?? []) as Array<{ role?: string; content?: unknown }>;
|
|
45
|
+
const last = msgs[msgs.length - 1];
|
|
46
|
+
const lastPreview = typeof last?.content === 'string'
|
|
47
|
+
? last.content.slice(0, 200)
|
|
48
|
+
: Array.isArray(last?.content)
|
|
49
|
+
? (last.content as Array<{ type?: string; text?: string }>)
|
|
50
|
+
.map((b) => (b.type === 'text' ? (b.text ?? '').slice(0, 120) : `[${b.type}]`))
|
|
51
|
+
.join(' | ').slice(0, 300)
|
|
52
|
+
: undefined;
|
|
53
|
+
return {
|
|
54
|
+
model: request.model,
|
|
55
|
+
maxTokens: request.maxTokens,
|
|
56
|
+
messageCount: msgs.length,
|
|
57
|
+
systemChars: typeof request.system === 'string'
|
|
58
|
+
? request.system.length
|
|
59
|
+
: Array.isArray(request.system)
|
|
60
|
+
? JSON.stringify(request.system).length
|
|
61
|
+
: 0,
|
|
62
|
+
toolNames: (request.tools as Array<{ name?: string }> | undefined)?.map((t) => t.name) ?? null,
|
|
63
|
+
toolCount: (request.tools as unknown[] | undefined)?.length ?? 0,
|
|
64
|
+
lastMessageRole: last?.role,
|
|
65
|
+
lastMessagePreview: lastPreview,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function summarizeResponse(response: ProviderResponse): Record<string, unknown> {
|
|
70
|
+
const content = (response.content ?? []) as Array<{ type?: string; text?: string; name?: string }>;
|
|
71
|
+
return {
|
|
72
|
+
stopReason: response.stopReason
|
|
73
|
+
?? (response.raw as { stop_reason?: string } | undefined)?.stop_reason
|
|
74
|
+
?? null,
|
|
75
|
+
usage: response.usage ?? null,
|
|
76
|
+
blocks: content.map((b) => ({
|
|
77
|
+
type: b.type,
|
|
78
|
+
...(b.type === 'text' ? { chars: (b.text ?? '').length, text: b.text } : {}),
|
|
79
|
+
...(b.type === 'tool_use' ? { name: b.name } : {}),
|
|
80
|
+
})),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class LoggingProviderAdapter implements ProviderAdapter {
|
|
85
|
+
readonly name: string;
|
|
86
|
+
|
|
87
|
+
constructor(
|
|
88
|
+
private readonly inner: ProviderAdapter,
|
|
89
|
+
private readonly logPath: string,
|
|
90
|
+
) {
|
|
91
|
+
this.name = inner.name;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
supportsModel(modelId: string): boolean {
|
|
95
|
+
return this.inner.supportsModel(modelId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private log(record: Record<string, unknown>): void {
|
|
99
|
+
try {
|
|
100
|
+
let line = JSON.stringify(record);
|
|
101
|
+
if (line.length > MAX_RECORD_BYTES) {
|
|
102
|
+
const { rawRequest: _rq, rawResponse: _rr, ...rest } = record;
|
|
103
|
+
line = JSON.stringify({ ...rest, truncated: 'raw bodies dropped (record exceeded size guard)' });
|
|
104
|
+
}
|
|
105
|
+
appendFileSync(this.logPath, line + '\n');
|
|
106
|
+
} catch {
|
|
107
|
+
// Logging must never break inference.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private record(
|
|
112
|
+
kind: 'complete' | 'stream',
|
|
113
|
+
request: ProviderRequest,
|
|
114
|
+
started: number,
|
|
115
|
+
response?: ProviderResponse,
|
|
116
|
+
error?: unknown,
|
|
117
|
+
): void {
|
|
118
|
+
this.log({
|
|
119
|
+
type: error === undefined ? 'call' : 'error',
|
|
120
|
+
provider: this.name,
|
|
121
|
+
kind,
|
|
122
|
+
timestamp: new Date(started).toISOString(),
|
|
123
|
+
durationMs: Date.now() - started,
|
|
124
|
+
requestSummary: summarizeRequest(request),
|
|
125
|
+
rawRequest: request,
|
|
126
|
+
...(response !== undefined
|
|
127
|
+
? { response: summarizeResponse(response), rawResponse: response.raw ?? null }
|
|
128
|
+
: {}),
|
|
129
|
+
...(error !== undefined
|
|
130
|
+
? { error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) }
|
|
131
|
+
: {}),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async complete(
|
|
136
|
+
request: ProviderRequest,
|
|
137
|
+
options?: ProviderRequestOptions,
|
|
138
|
+
): Promise<ProviderResponse> {
|
|
139
|
+
const started = Date.now();
|
|
140
|
+
try {
|
|
141
|
+
const response = await this.inner.complete(request, options);
|
|
142
|
+
this.record('complete', request, started, response);
|
|
143
|
+
return response;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.record('complete', request, started, undefined, error);
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async stream(
|
|
151
|
+
request: ProviderRequest,
|
|
152
|
+
callbacks: StreamCallbacks,
|
|
153
|
+
options?: ProviderRequestOptions,
|
|
154
|
+
): Promise<ProviderResponse> {
|
|
155
|
+
const started = Date.now();
|
|
156
|
+
try {
|
|
157
|
+
const response = await this.inner.stream(request, callbacks, options);
|
|
158
|
+
this.record('stream', request, started, response);
|
|
159
|
+
return response;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.record('stream', request, started, undefined, error);
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
type TokenUsage,
|
|
59
59
|
type CallLedgerSnapshot,
|
|
60
60
|
type McplListMessage,
|
|
61
|
+
type SettingsStateMessage,
|
|
61
62
|
type BranchesListMessage,
|
|
62
63
|
type LessonsListMessage,
|
|
63
64
|
} from '../web/protocol.js';
|
|
@@ -1076,6 +1077,9 @@ export class WebUiModule implements Module {
|
|
|
1076
1077
|
if (url.pathname === '/debug/context/maintenance') {
|
|
1077
1078
|
return this.handleContextMaintenance();
|
|
1078
1079
|
}
|
|
1080
|
+
if (url.pathname === '/debug/context/preview') {
|
|
1081
|
+
return this.handleContextPreview(url);
|
|
1082
|
+
}
|
|
1079
1083
|
if (url.pathname === '/curve') {
|
|
1080
1084
|
return new Response(CURVE_PAGE_HTML, {
|
|
1081
1085
|
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
@@ -1181,6 +1185,79 @@ export class WebUiModule implements Module {
|
|
|
1181
1185
|
return Response.json(buildContextCoverageSnapshot(agentName, cm));
|
|
1182
1186
|
}
|
|
1183
1187
|
|
|
1188
|
+
/**
|
|
1189
|
+
* Preview the fold plan at a HYPOTHETICAL budget / tail, without applying it.
|
|
1190
|
+
*
|
|
1191
|
+
* GET /debug/context/preview?budget=<tokens>[&tail=<tokens>][&agent=<name>]
|
|
1192
|
+
*
|
|
1193
|
+
* Commits nothing — no fold resolutions persisted, no compression enqueued,
|
|
1194
|
+
* no transition bookkeeping advanced. That guarantee lives in
|
|
1195
|
+
* context-manager's `previewContext` (dry-run select); this endpoint only
|
|
1196
|
+
* forwards. An infeasible budget is reported as `fits: false` with the
|
|
1197
|
+
* per-component diagnostics, NOT as an error: learning that a budget can't
|
|
1198
|
+
* work is the reason to preview instead of applying and taking the outage.
|
|
1199
|
+
*
|
|
1200
|
+
* Returns 501 when the resolved context-manager predates dry-run support, so
|
|
1201
|
+
* the panel can say so instead of rendering an empty result.
|
|
1202
|
+
*/
|
|
1203
|
+
private handleContextPreview(url: URL): Response {
|
|
1204
|
+
const app = sharedServer?.app;
|
|
1205
|
+
if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
|
|
1206
|
+
const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
|
|
1207
|
+
const agent = app.framework.getAgent(agentName);
|
|
1208
|
+
if (!agent) {
|
|
1209
|
+
return Response.json({ error: `Agent not found: ${agentName}` }, { status: 404 });
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const budgetRaw = url.searchParams.get('budget');
|
|
1213
|
+
const budget = budgetRaw === null ? NaN : Number(budgetRaw);
|
|
1214
|
+
if (!Number.isSafeInteger(budget) || budget <= 0) {
|
|
1215
|
+
return Response.json({ error: 'budget must be a positive integer' }, { status: 400 });
|
|
1216
|
+
}
|
|
1217
|
+
const tailRaw = url.searchParams.get('tail');
|
|
1218
|
+
const overrides: Record<string, unknown> = {};
|
|
1219
|
+
if (tailRaw !== null) {
|
|
1220
|
+
const tail = Number(tailRaw);
|
|
1221
|
+
if (!Number.isSafeInteger(tail) || tail < 0) {
|
|
1222
|
+
return Response.json({ error: 'tail must be a non-negative integer' }, { status: 400 });
|
|
1223
|
+
}
|
|
1224
|
+
// The strategy knob behind "tail" is recentWindowTokens.
|
|
1225
|
+
overrides.recentWindowTokens = tail;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const fw = app.framework as unknown as {
|
|
1229
|
+
previewContextSettings?: (n: string, b: number, o?: Record<string, unknown>) => unknown;
|
|
1230
|
+
};
|
|
1231
|
+
if (typeof fw.previewContextSettings !== 'function') {
|
|
1232
|
+
return Response.json(
|
|
1233
|
+
{ error: 'preview unsupported: this agent-framework build has no previewContextSettings' },
|
|
1234
|
+
{ status: 501 },
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
try {
|
|
1238
|
+
const result = fw.previewContextSettings(
|
|
1239
|
+
agentName,
|
|
1240
|
+
budget,
|
|
1241
|
+
Object.keys(overrides).length > 0 ? overrides : undefined,
|
|
1242
|
+
);
|
|
1243
|
+
if (result === null || result === undefined) {
|
|
1244
|
+
return Response.json(
|
|
1245
|
+
{
|
|
1246
|
+
error: 'preview unavailable: the resolved context-manager has no dry-run support, '
|
|
1247
|
+
+ 'or the active strategy has no fold plan (non-adaptive)',
|
|
1248
|
+
},
|
|
1249
|
+
{ status: 501 },
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
return Response.json({ agent: agentName, budget, ...(overrides as object), preview: result });
|
|
1253
|
+
} catch (err) {
|
|
1254
|
+
return Response.json(
|
|
1255
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
1256
|
+
{ status: 500 },
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1184
1261
|
/**
|
|
1185
1262
|
* Debug endpoint: return the membrane-normalized request the framework would
|
|
1186
1263
|
* hand to the model if the agent were activated right now. Delegates to
|
|
@@ -1254,7 +1331,18 @@ export class WebUiModule implements Module {
|
|
|
1254
1331
|
}
|
|
1255
1332
|
try {
|
|
1256
1333
|
const cm = (agent as unknown as { getContextManager: () => any }).getContextManager();
|
|
1257
|
-
|
|
1334
|
+
// Use the LIVE budget, not the recipe's. Runtime overrides persist in the
|
|
1335
|
+
// `framework/state` Chronicle slot and win over the recipe, so reading
|
|
1336
|
+
// app.recipe here plotted the wrong curve on any agent whose budget had
|
|
1337
|
+
// ever been changed at runtime — which is every agent the settings panel
|
|
1338
|
+
// touches. Fall back to the recipe only if the live read is unavailable.
|
|
1339
|
+
let maxTokens = app.recipe.agent.contextBudgetTokens ?? 200_000;
|
|
1340
|
+
try {
|
|
1341
|
+
const live = (app.framework as unknown as {
|
|
1342
|
+
getAgentRuntimeSettings?: (n: string) => { contextBudgetTokens?: number };
|
|
1343
|
+
}).getAgentRuntimeSettings?.(agentName)?.contextBudgetTokens;
|
|
1344
|
+
if (typeof live === 'number' && live > 0) maxTokens = live;
|
|
1345
|
+
} catch { /* keep the recipe fallback */ }
|
|
1258
1346
|
const reserveForResponse = app.recipe.agent.maxTokens ?? 16_384;
|
|
1259
1347
|
const compiled = await cm.compile({ maxTokens, reserveForResponse });
|
|
1260
1348
|
|
|
@@ -1763,6 +1851,78 @@ export class WebUiModule implements Module {
|
|
|
1763
1851
|
return;
|
|
1764
1852
|
}
|
|
1765
1853
|
|
|
1854
|
+
case 'request-settings': {
|
|
1855
|
+
this.sendSettingsState(client, parsed.agent);
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// Settings mutations are live process state, so every case BROADCASTS
|
|
1860
|
+
// rather than replying to the requester only (contrast sendMcplList,
|
|
1861
|
+
// which is file-only). Two operators must not see divergent budgets.
|
|
1862
|
+
case 'settings-update': {
|
|
1863
|
+
const agentName = this.resolveSettingsAgent(parsed.agent);
|
|
1864
|
+
try {
|
|
1865
|
+
const patch: Record<string, number> = {};
|
|
1866
|
+
if (parsed.contextBudgetTokens !== undefined) patch.contextBudgetTokens = parsed.contextBudgetTokens;
|
|
1867
|
+
if (parsed.tailTokens !== undefined) patch.tailTokens = parsed.tailTokens;
|
|
1868
|
+
if (parsed.transitionPaceTokens !== undefined) patch.transitionPaceTokens = parsed.transitionPaceTokens;
|
|
1869
|
+
const fw = sharedServer!.app.framework as unknown as {
|
|
1870
|
+
updateAgentRuntimeSettings: (n: string, p: unknown, o?: { persist?: boolean }) => unknown;
|
|
1871
|
+
};
|
|
1872
|
+
fw.updateAgentRuntimeSettings(agentName, patch, { persist: parsed.persist !== false });
|
|
1873
|
+
} catch (err) {
|
|
1874
|
+
// Expected failures land here and must reach the operator verbatim:
|
|
1875
|
+
// budget ≤ max response tokens, or a strategy that cannot prepare a
|
|
1876
|
+
// smaller window live. Silently swallowing them would look like the
|
|
1877
|
+
// apply worked.
|
|
1878
|
+
this.send(client, {
|
|
1879
|
+
type: 'error',
|
|
1880
|
+
message: `settings-update failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1881
|
+
});
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
if (parsed.notify === true) this.notifyAgentOfSettingsChange(agentName, 'update');
|
|
1885
|
+
this.broadcastSettingsState(agentName);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
case 'settings-reset': {
|
|
1890
|
+
const agentName = this.resolveSettingsAgent(parsed.agent);
|
|
1891
|
+
try {
|
|
1892
|
+
const fw = sharedServer!.app.framework as unknown as {
|
|
1893
|
+
resetAgentRuntimeSettings: (n: string, k?: string[], o?: { persist?: boolean }) => unknown;
|
|
1894
|
+
};
|
|
1895
|
+
fw.resetAgentRuntimeSettings(agentName, parsed.keys, { persist: parsed.persist !== false });
|
|
1896
|
+
} catch (err) {
|
|
1897
|
+
this.send(client, {
|
|
1898
|
+
type: 'error',
|
|
1899
|
+
message: `settings-reset failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1900
|
+
});
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (parsed.notify === true) this.notifyAgentOfSettingsChange(agentName, 'reset');
|
|
1904
|
+
this.broadcastSettingsState(agentName);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
case 'settings-cancel-transition': {
|
|
1909
|
+
const agentName = this.resolveSettingsAgent(parsed.agent);
|
|
1910
|
+
try {
|
|
1911
|
+
const fw = sharedServer!.app.framework as unknown as {
|
|
1912
|
+
cancelAgentRuntimeSettingsTransition: (n: string) => unknown;
|
|
1913
|
+
};
|
|
1914
|
+
fw.cancelAgentRuntimeSettingsTransition(agentName);
|
|
1915
|
+
} catch (err) {
|
|
1916
|
+
this.send(client, {
|
|
1917
|
+
type: 'error',
|
|
1918
|
+
message: `settings-cancel-transition failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1919
|
+
});
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
this.broadcastSettingsState(agentName);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1766
1926
|
case 'request-workspace-mounts': {
|
|
1767
1927
|
if (parsed.scope && parsed.scope !== 'local') {
|
|
1768
1928
|
this.routeFleetRequest(client, parsed.scope, 'workspace-mounts',
|
|
@@ -2426,6 +2586,125 @@ export class WebUiModule implements Module {
|
|
|
2426
2586
|
}
|
|
2427
2587
|
}
|
|
2428
2588
|
|
|
2589
|
+
/** Default to the recipe's primary agent when the client omits a name. */
|
|
2590
|
+
private resolveSettingsAgent(name?: string): string {
|
|
2591
|
+
if (name) return name;
|
|
2592
|
+
const app = sharedServer?.app;
|
|
2593
|
+
return app?.recipe.agent.name ?? app?.framework.getAllAgents()[0]?.name ?? 'agent';
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
/**
|
|
2597
|
+
* Build the settings snapshot. Never throws: this feeds a panel, and a
|
|
2598
|
+
* strategy that isn't hot-configurable is a legitimate state to render
|
|
2599
|
+
* read-only rather than an error to surface.
|
|
2600
|
+
*/
|
|
2601
|
+
private buildSettingsState(agentName: string): SettingsStateMessage | null {
|
|
2602
|
+
const app = sharedServer?.app;
|
|
2603
|
+
if (!app) return null;
|
|
2604
|
+
const fw = app.framework as unknown as {
|
|
2605
|
+
getAgentRuntimeSettings?: (n: string) => Record<string, unknown>;
|
|
2606
|
+
getAgent?: (n: string) => unknown;
|
|
2607
|
+
};
|
|
2608
|
+
if (typeof fw.getAgentRuntimeSettings !== 'function') return null;
|
|
2609
|
+
|
|
2610
|
+
let settings: Record<string, unknown>;
|
|
2611
|
+
try {
|
|
2612
|
+
settings = fw.getAgentRuntimeSettings(agentName);
|
|
2613
|
+
} catch {
|
|
2614
|
+
return null;
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// Which knobs this build can apply live, probed rather than assumed: the
|
|
2618
|
+
// hot set is a property of the ACTIVE strategy, not of the host version.
|
|
2619
|
+
let hotConfigurable = false;
|
|
2620
|
+
let previewAvailable = false;
|
|
2621
|
+
try {
|
|
2622
|
+
const agent = app.framework.getAgent(agentName);
|
|
2623
|
+
const cm = agent?.getContextManager() as unknown as {
|
|
2624
|
+
getHotContextSettings?: () => unknown;
|
|
2625
|
+
previewContext?: unknown;
|
|
2626
|
+
} | undefined;
|
|
2627
|
+
hotConfigurable = !!cm && typeof cm.getHotContextSettings === 'function'
|
|
2628
|
+
&& cm.getHotContextSettings() !== null;
|
|
2629
|
+
// Older context-manager builds resolve without previewContext. Report it
|
|
2630
|
+
// so the panel says "preview unavailable on this build" instead of
|
|
2631
|
+
// rendering an empty chart and looking broken.
|
|
2632
|
+
previewAvailable = !!cm && typeof cm.previewContext === 'function';
|
|
2633
|
+
} catch { /* leave both false — read-only panel */ }
|
|
2634
|
+
|
|
2635
|
+
const overrides: string[] = [];
|
|
2636
|
+
try {
|
|
2637
|
+
const agent = app.framework.getAgent(agentName) as unknown as {
|
|
2638
|
+
getRuntimeSettingsOverrides?: () => Record<string, unknown>;
|
|
2639
|
+
} | undefined;
|
|
2640
|
+
const ov = agent?.getRuntimeSettingsOverrides?.() ?? {};
|
|
2641
|
+
for (const [k, val] of Object.entries(ov)) if (val !== undefined) overrides.push(k);
|
|
2642
|
+
} catch { /* informational only */ }
|
|
2643
|
+
|
|
2644
|
+
return {
|
|
2645
|
+
type: 'settings-state',
|
|
2646
|
+
agent: agentName,
|
|
2647
|
+
settings: settings as SettingsStateMessage['settings'],
|
|
2648
|
+
overrides,
|
|
2649
|
+
// contextBudgetTokens is applied by the Agent itself; the other three are
|
|
2650
|
+
// forwarded into the strategy's hot-settings channel, so they need a
|
|
2651
|
+
// hot-configurable strategy to mean anything.
|
|
2652
|
+
hotKeys: hotConfigurable
|
|
2653
|
+
? ['contextBudgetTokens', 'tailTokens', 'transitionPaceTokens', 'sameRoundThinkTextPolicy']
|
|
2654
|
+
: ['contextBudgetTokens'],
|
|
2655
|
+
hotConfigurable,
|
|
2656
|
+
previewAvailable,
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
private sendSettingsState(client: ClientState, agentName?: string): void {
|
|
2661
|
+
const msg = this.buildSettingsState(this.resolveSettingsAgent(agentName));
|
|
2662
|
+
if (!msg) {
|
|
2663
|
+
this.send(client, { type: 'error', message: 'runtime settings unavailable on this build' });
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
this.send(client, msg);
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
/** Fan out to every welcomed client — settings are live process state. */
|
|
2670
|
+
private broadcastSettingsState(agentName: string): void {
|
|
2671
|
+
if (!sharedServer?.app) return;
|
|
2672
|
+
const msg = this.buildSettingsState(agentName);
|
|
2673
|
+
if (!msg) return;
|
|
2674
|
+
for (const c of sharedServer.clients.values()) {
|
|
2675
|
+
if (c.welcomed) this.send(c, msg);
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
/**
|
|
2680
|
+
* Opt-in push notice to the agent that an operator changed its context
|
|
2681
|
+
* settings. OFF by default at the protocol level, because this injects text
|
|
2682
|
+
* into the very context being tuned: it invalidates the KV prefix and is
|
|
2683
|
+
* itself classifier-visible. The zero-cost alternative the agent always has
|
|
2684
|
+
* is to PULL via its `agent_settings` tool.
|
|
2685
|
+
*/
|
|
2686
|
+
private notifyAgentOfSettingsChange(agentName: string, kind: 'update' | 'reset'): void {
|
|
2687
|
+
try {
|
|
2688
|
+
const app = sharedServer?.app;
|
|
2689
|
+
if (!app) return;
|
|
2690
|
+
const s = this.buildSettingsState(agentName);
|
|
2691
|
+
const budget = s?.settings.contextBudgetTokens;
|
|
2692
|
+
const tail = s?.settings.tailTokens;
|
|
2693
|
+
const transition = s?.settings.transition;
|
|
2694
|
+
const text = kind === 'reset'
|
|
2695
|
+
? `[operator] context settings reset to recipe defaults`
|
|
2696
|
+
: `[operator] context settings changed`
|
|
2697
|
+
+ (budget !== undefined ? ` — budget ${budget}` : '')
|
|
2698
|
+
+ (tail !== undefined ? `, tail ${tail}` : '')
|
|
2699
|
+
+ (transition === 'converging' ? ' (converging gradually)' : '');
|
|
2700
|
+
const cm = app.framework.getAgent(agentName)?.getContextManager();
|
|
2701
|
+
cm?.addMessage('Context Manager', [{ type: 'text', text }], { system: true });
|
|
2702
|
+
} catch (err) {
|
|
2703
|
+
// A failed notice must never fail the apply that already succeeded.
|
|
2704
|
+
console.warn('[settings] notify failed (change still applied):', err);
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2429
2708
|
private broadcastBranchChanged(): void {
|
|
2430
2709
|
if (!sharedServer?.app) return;
|
|
2431
2710
|
const cm = sharedServer?.app.framework.getAllAgents()[0]?.getContextManager();
|