@downcity/agent 1.1.232 → 1.1.237

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.
Files changed (165) hide show
  1. package/bin/agent/local/Agent.d.ts +1 -1
  2. package/bin/agent/local/Agent.js +2 -2
  3. package/bin/agent/local/Agent.js.map +1 -1
  4. package/bin/agent/local/services/AgentSessions.d.ts +4 -4
  5. package/bin/agent/local/services/AgentSessions.d.ts.map +1 -1
  6. package/bin/agent/local/services/AgentSessions.js +8 -8
  7. package/bin/agent/local/services/AgentSessions.js.map +1 -1
  8. package/bin/agent/remote/RemoteSession.d.ts +2 -0
  9. package/bin/agent/remote/RemoteSession.d.ts.map +1 -1
  10. package/bin/agent/remote/RemoteSession.js +4 -0
  11. package/bin/agent/remote/RemoteSession.js.map +1 -1
  12. package/bin/agent/remote/RemoteTransport.d.ts +2 -0
  13. package/bin/agent/remote/RemoteTransport.d.ts.map +1 -1
  14. package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts +1 -0
  15. package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts.map +1 -1
  16. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js +11 -0
  17. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js.map +1 -1
  18. package/bin/agent/remote/transports/RpcRemoteAgentTransport.d.ts +1 -0
  19. package/bin/agent/remote/transports/RpcRemoteAgentTransport.d.ts.map +1 -1
  20. package/bin/agent/remote/transports/RpcRemoteAgentTransport.js +3 -0
  21. package/bin/agent/remote/transports/RpcRemoteAgentTransport.js.map +1 -1
  22. package/bin/executor/Executor.d.ts +20 -2
  23. package/bin/executor/Executor.d.ts.map +1 -1
  24. package/bin/executor/Executor.js +102 -18
  25. package/bin/executor/Executor.js.map +1 -1
  26. package/bin/executor/composer/compaction/SessionCompactionComposer.d.ts +6 -0
  27. package/bin/executor/composer/compaction/SessionCompactionComposer.d.ts.map +1 -1
  28. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.d.ts +0 -9
  29. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.d.ts.map +1 -1
  30. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.js +4 -38
  31. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.js.map +1 -1
  32. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.d.ts +1 -3
  33. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.d.ts.map +1 -1
  34. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.js +7 -59
  35. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.js.map +1 -1
  36. package/bin/executor/core-engine/CoreEngineContextCompaction.d.ts +38 -0
  37. package/bin/executor/core-engine/CoreEngineContextCompaction.d.ts.map +1 -0
  38. package/bin/executor/core-engine/CoreEngineContextCompaction.js +392 -0
  39. package/bin/executor/core-engine/CoreEngineContextCompaction.js.map +1 -0
  40. package/bin/executor/core-engine/CoreEngineMessageState.d.ts +15 -0
  41. package/bin/executor/core-engine/CoreEngineMessageState.d.ts.map +1 -1
  42. package/bin/executor/core-engine/CoreEngineMessageState.js +20 -0
  43. package/bin/executor/core-engine/CoreEngineMessageState.js.map +1 -1
  44. package/bin/executor/core-engine/CoreEngineRunner.d.ts +9 -5
  45. package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
  46. package/bin/executor/core-engine/CoreEngineRunner.js +146 -35
  47. package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
  48. package/bin/executor/store/history/SessionHistoryStore.d.ts +4 -10
  49. package/bin/executor/store/history/SessionHistoryStore.d.ts.map +1 -1
  50. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.d.ts.map +1 -1
  51. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js +1 -3
  52. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js.map +1 -1
  53. package/bin/executor/types/SessionRun.d.ts +8 -0
  54. package/bin/executor/types/SessionRun.d.ts.map +1 -1
  55. package/bin/index.d.ts +1 -1
  56. package/bin/index.d.ts.map +1 -1
  57. package/bin/index.js +1 -1
  58. package/bin/index.js.map +1 -1
  59. package/bin/plugin/core/PluginLocalExecution.js +3 -3
  60. package/bin/plugin/core/PluginLocalExecution.js.map +1 -1
  61. package/bin/plugin/core/PluginRegistry.d.ts +20 -2
  62. package/bin/plugin/core/PluginRegistry.d.ts.map +1 -1
  63. package/bin/plugin/core/PluginRegistry.js +100 -3
  64. package/bin/plugin/core/PluginRegistry.js.map +1 -1
  65. package/bin/rpc/Client.d.ts +6 -4
  66. package/bin/rpc/Client.d.ts.map +1 -1
  67. package/bin/rpc/Client.js +11 -0
  68. package/bin/rpc/Client.js.map +1 -1
  69. package/bin/session/Session.d.ts +7 -3
  70. package/bin/session/Session.d.ts.map +1 -1
  71. package/bin/session/Session.js +26 -19
  72. package/bin/session/Session.js.map +1 -1
  73. package/bin/session/SessionSystemBuilder.d.ts.map +1 -1
  74. package/bin/session/SessionSystemBuilder.js +3 -1
  75. package/bin/session/SessionSystemBuilder.js.map +1 -1
  76. package/bin/session/recorder/SessionRecorderCompaction.d.ts.map +1 -1
  77. package/bin/session/recorder/SessionRecorderCompaction.js +102 -60
  78. package/bin/session/recorder/SessionRecorderCompaction.js.map +1 -1
  79. package/bin/session/runtime/SessionPromptRuntime.d.ts +11 -7
  80. package/bin/session/runtime/SessionPromptRuntime.d.ts.map +1 -1
  81. package/bin/session/runtime/SessionPromptRuntime.js +22 -24
  82. package/bin/session/runtime/SessionPromptRuntime.js.map +1 -1
  83. package/bin/session/services/SessionStateService.d.ts +10 -6
  84. package/bin/session/services/SessionStateService.d.ts.map +1 -1
  85. package/bin/session/services/SessionStateService.js +29 -20
  86. package/bin/session/services/SessionStateService.js.map +1 -1
  87. package/bin/session/services/SessionTurnService.d.ts +13 -3
  88. package/bin/session/services/SessionTurnService.d.ts.map +1 -1
  89. package/bin/session/services/SessionTurnService.js +90 -7
  90. package/bin/session/services/SessionTurnService.js.map +1 -1
  91. package/bin/types/agent/SessionActor.d.ts +8 -0
  92. package/bin/types/agent/SessionActor.d.ts.map +1 -1
  93. package/bin/types/executor/SessionRunContext.d.ts +16 -8
  94. package/bin/types/executor/SessionRunContext.d.ts.map +1 -1
  95. package/bin/types/plugin/PluginRuntime.d.ts +36 -4
  96. package/bin/types/plugin/PluginRuntime.d.ts.map +1 -1
  97. package/bin/types/plugin/PluginState.d.ts +10 -0
  98. package/bin/types/plugin/PluginState.d.ts.map +1 -1
  99. package/bin/types/rpc/RpcProtocol.d.ts +21 -0
  100. package/bin/types/rpc/RpcProtocol.d.ts.map +1 -1
  101. package/bin/types/runtime/agent/AgentContext.d.ts +2 -2
  102. package/bin/types/runtime/agent/AgentContext.js +1 -1
  103. package/bin/types/session/SessionLocalState.d.ts +2 -2
  104. package/bin/types/session/SessionOptions.d.ts +5 -5
  105. package/bin/types/session/SessionOptions.d.ts.map +1 -1
  106. package/bin/types/session/SessionQueueCommand.d.ts +68 -0
  107. package/bin/types/session/SessionQueueCommand.d.ts.map +1 -0
  108. package/bin/types/session/SessionQueueCommand.js +10 -0
  109. package/bin/types/session/SessionQueueCommand.js.map +1 -0
  110. package/bin/utils/logger/Logger.d.ts +7 -9
  111. package/bin/utils/logger/Logger.d.ts.map +1 -1
  112. package/bin/utils/logger/Logger.js +11 -9
  113. package/bin/utils/logger/Logger.js.map +1 -1
  114. package/package.json +3 -3
  115. package/scripts/core-engine-context-compaction.test.mjs +411 -0
  116. package/scripts/logger-isolation.test.mjs +55 -0
  117. package/scripts/multi-agent-plugin-isolation.test.mjs +289 -0
  118. package/scripts/plugin-tool-bridge.test.mjs +52 -0
  119. package/scripts/remote-session-reconnect.test.mjs +18 -0
  120. package/scripts/session-compaction-context-window.test.mjs +27 -26
  121. package/scripts/session-config-turn-boundary.test.mjs +80 -26
  122. package/scripts/session-prompt-runtime.test.mjs +23 -15
  123. package/scripts/session-recorder.test.mjs +15 -24
  124. package/src/agent/local/Agent.ts +2 -2
  125. package/src/agent/local/services/AgentSessions.ts +10 -10
  126. package/src/agent/remote/RemoteSession.ts +5 -0
  127. package/src/agent/remote/RemoteTransport.ts +2 -0
  128. package/src/agent/remote/transports/HttpRemoteAgentTransport.ts +15 -0
  129. package/src/agent/remote/transports/RpcRemoteAgentTransport.ts +4 -0
  130. package/src/executor/Executor.ts +111 -19
  131. package/src/executor/composer/compaction/SessionCompactionComposer.ts +7 -0
  132. package/src/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.ts +4 -57
  133. package/src/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.ts +8 -66
  134. package/src/executor/core-engine/CoreEngineContextCompaction.ts +477 -0
  135. package/src/executor/core-engine/CoreEngineMessageState.ts +29 -0
  136. package/src/executor/core-engine/CoreEngineRunner.ts +200 -48
  137. package/src/executor/store/history/SessionHistoryStore.ts +4 -12
  138. package/src/executor/store/history/jsonl/JsonlSessionHistoryStore.ts +1 -3
  139. package/src/executor/types/SessionRun.ts +9 -0
  140. package/src/index.ts +1 -1
  141. package/src/plugin/core/PluginLocalExecution.ts +3 -3
  142. package/src/plugin/core/PluginRegistry.ts +116 -3
  143. package/src/rpc/Client.ts +15 -2
  144. package/src/session/Session.ts +28 -20
  145. package/src/session/SessionSystemBuilder.ts +4 -1
  146. package/src/session/recorder/SessionRecorderCompaction.ts +113 -65
  147. package/src/session/runtime/SessionPromptRuntime.ts +31 -46
  148. package/src/session/services/SessionStateService.ts +34 -25
  149. package/src/session/services/SessionTurnService.ts +97 -8
  150. package/src/types/agent/SessionActor.ts +9 -0
  151. package/src/types/executor/SessionRunContext.ts +17 -8
  152. package/src/types/plugin/PluginRuntime.ts +38 -4
  153. package/src/types/plugin/PluginState.ts +10 -0
  154. package/src/types/rpc/RpcProtocol.ts +23 -0
  155. package/src/types/runtime/agent/AgentContext.ts +2 -2
  156. package/src/types/session/SessionLocalState.ts +2 -2
  157. package/src/types/session/SessionOptions.ts +6 -6
  158. package/src/types/session/SessionQueueCommand.ts +78 -0
  159. package/src/utils/logger/Logger.ts +10 -10
  160. package/tsconfig.tsbuildinfo +1 -1
  161. package/bin/types/session/SessionConfigMutation.d.ts +0 -68
  162. package/bin/types/session/SessionConfigMutation.d.ts.map +0 -1
  163. package/bin/types/session/SessionConfigMutation.js +0 -10
  164. package/bin/types/session/SessionConfigMutation.js.map +0 -1
  165. package/src/types/session/SessionConfigMutation.ts +0 -78
@@ -0,0 +1,289 @@
1
+ /**
2
+ * @file 验证多个 Agent 经 session.prompt 执行 plugin_call 时保持 registry 隔离。
3
+ *
4
+ * 关键点(中文)
5
+ * - 先创建 Agent A,再创建 Agent B,锁住历史全局 runtime 的覆盖顺序。
6
+ * - 两个 Session 并发进入模型 tool loop,验证异步作用域不会造成 registry 串线。
7
+ * - 第二次模型请求必须收到各自 plugin action 返回的 owner。
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import http from "node:http";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import fs from "node:fs/promises";
16
+
17
+ import { Agent } from "../bin/index.js";
18
+ import {
19
+ createAction,
20
+ createPlugin,
21
+ } from "../bin/plugin/core/PluginActionFactory.js";
22
+ import { CITY_MODEL_INVOKER, CITY_MODEL_KIND } from "@downcity/type";
23
+
24
+ /**
25
+ * 写入一组 OpenAI-compatible SSE chunk。
26
+ */
27
+ function write_openai_sse(response, chunks) {
28
+ response.writeHead(200, {
29
+ "content-type": "text/event-stream; charset=utf-8",
30
+ "cache-control": "no-cache",
31
+ connection: "keep-alive",
32
+ });
33
+ for (const chunk of chunks) {
34
+ const payload = typeof chunk === "string" ? chunk : JSON.stringify(chunk);
35
+ response.write(`data: ${payload}\n\n`);
36
+ }
37
+ response.end();
38
+ }
39
+
40
+ /**
41
+ * 读取 HTTP JSON 请求体。
42
+ */
43
+ async function read_json_body(request) {
44
+ const raw = await new Promise((resolve, reject) => {
45
+ let data = "";
46
+ request.on("data", (chunk) => {
47
+ data += chunk;
48
+ });
49
+ request.on("end", () => resolve(data));
50
+ request.on("error", reject);
51
+ });
52
+ return JSON.parse(String(raw || "{}"));
53
+ }
54
+
55
+ /**
56
+ * 返回普通文本模型流。
57
+ */
58
+ function write_text_response(response, input) {
59
+ write_openai_sse(response, [
60
+ {
61
+ id: input.id,
62
+ object: "chat.completion.chunk",
63
+ created: 1,
64
+ model: input.model,
65
+ choices: [
66
+ {
67
+ index: 0,
68
+ delta: { role: "assistant", content: input.text },
69
+ finish_reason: null,
70
+ },
71
+ ],
72
+ },
73
+ {
74
+ id: input.id,
75
+ object: "chat.completion.chunk",
76
+ created: 1,
77
+ model: input.model,
78
+ choices: [
79
+ {
80
+ index: 0,
81
+ delta: {},
82
+ finish_reason: "stop",
83
+ },
84
+ ],
85
+ },
86
+ "[DONE]",
87
+ ]);
88
+ }
89
+
90
+ /**
91
+ * 返回一次 plugin_call tool call。
92
+ */
93
+ function write_plugin_call_response(response, model) {
94
+ write_openai_sse(response, [
95
+ {
96
+ id: `chatcmpl_${model}_tool`,
97
+ object: "chat.completion.chunk",
98
+ created: 1,
99
+ model,
100
+ choices: [
101
+ {
102
+ index: 0,
103
+ delta: { role: "assistant" },
104
+ finish_reason: null,
105
+ },
106
+ ],
107
+ },
108
+ {
109
+ id: `chatcmpl_${model}_tool`,
110
+ object: "chat.completion.chunk",
111
+ created: 1,
112
+ model,
113
+ choices: [
114
+ {
115
+ index: 0,
116
+ delta: {
117
+ tool_calls: [
118
+ {
119
+ index: 0,
120
+ id: `call_${model}`,
121
+ type: "function",
122
+ function: {
123
+ name: "plugin_call",
124
+ arguments: JSON.stringify({
125
+ plugin: "skill",
126
+ action: "lookup",
127
+ payload: {},
128
+ }),
129
+ },
130
+ },
131
+ ],
132
+ },
133
+ finish_reason: null,
134
+ },
135
+ ],
136
+ },
137
+ {
138
+ id: `chatcmpl_${model}_tool`,
139
+ object: "chat.completion.chunk",
140
+ created: 1,
141
+ model,
142
+ choices: [
143
+ {
144
+ index: 0,
145
+ delta: {},
146
+ finish_reason: "tool_calls",
147
+ },
148
+ ],
149
+ },
150
+ "[DONE]",
151
+ ]);
152
+ }
153
+
154
+ /**
155
+ * 创建绑定测试 HTTP 服务的 CityModel。
156
+ */
157
+ function create_test_model(base_url, model_id) {
158
+ return Object.freeze({
159
+ id: model_id,
160
+ name: model_id,
161
+ description: "Multi-agent plugin isolation model",
162
+ modalities: ["text", "stream"],
163
+ tags: [],
164
+ meta: {},
165
+ kind: CITY_MODEL_KIND,
166
+ [CITY_MODEL_INVOKER]: {
167
+ connection: () => ({
168
+ base_url,
169
+ api_key: "test_key",
170
+ model_id,
171
+ }),
172
+ },
173
+ });
174
+ }
175
+
176
+ /**
177
+ * 创建返回固定 owner 的 skill plugin。
178
+ */
179
+ function create_owner_plugin(owner, executed_owners) {
180
+ return createPlugin({
181
+ name: "skill",
182
+ title: `Skill ${owner}`,
183
+ description: "Return the owning Agent id",
184
+ actions: {
185
+ lookup: createAction({
186
+ description: "Return registry owner",
187
+ execute: async () => {
188
+ executed_owners.push(owner);
189
+ return {
190
+ success: true,
191
+ data: { owner },
192
+ message: owner,
193
+ };
194
+ },
195
+ }),
196
+ },
197
+ });
198
+ }
199
+
200
+ test("multiple session prompts use only their owning Agent plugin registry", async () => {
201
+ const model_requests = new Map();
202
+ const executed_owners = [];
203
+ const server = http.createServer(async (request, response) => {
204
+ const url = new URL(String(request.url || "/"), "http://127.0.0.1");
205
+ if (
206
+ request.method !== "POST" ||
207
+ url.pathname !== "/v1/ai/chat/completions"
208
+ ) {
209
+ response.writeHead(404);
210
+ response.end("not found");
211
+ return;
212
+ }
213
+
214
+ const body = await read_json_body(request);
215
+ const model = String(body.model || "");
216
+ if (!Array.isArray(body.tools)) {
217
+ write_text_response(response, {
218
+ id: `chatcmpl_${model}_title`,
219
+ model,
220
+ text: `Title ${model}`,
221
+ });
222
+ return;
223
+ }
224
+
225
+ const requests = model_requests.get(model) || [];
226
+ requests.push(body);
227
+ model_requests.set(model, requests);
228
+ if (requests.length === 1) {
229
+ write_plugin_call_response(response, model);
230
+ return;
231
+ }
232
+ write_text_response(response, {
233
+ id: `chatcmpl_${model}_done`,
234
+ model,
235
+ text: "done",
236
+ });
237
+ });
238
+
239
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
240
+ const address = server.address();
241
+ assert.ok(address && typeof address === "object");
242
+ const base_url = `http://127.0.0.1:${String(address.port)}/v1/ai`;
243
+ const root_a = await fs.mkdtemp(
244
+ path.join(os.tmpdir(), "downcity-plugin-isolation-a-"),
245
+ );
246
+ const root_b = await fs.mkdtemp(
247
+ path.join(os.tmpdir(), "downcity-plugin-isolation-b-"),
248
+ );
249
+ const agent_a = new Agent({
250
+ id: "agent_a",
251
+ path: root_a,
252
+ plugins: [create_owner_plugin("agent_a", executed_owners)],
253
+ model: create_test_model(base_url, "model_a"),
254
+ });
255
+ const agent_b = new Agent({
256
+ id: "agent_b",
257
+ path: root_b,
258
+ plugins: [create_owner_plugin("agent_b", executed_owners)],
259
+ model: create_test_model(base_url, "model_b"),
260
+ });
261
+
262
+ try {
263
+ const session_a = await agent_a.sessions.create({ sessionId: "session_a" });
264
+ const session_b = await agent_b.sessions.create({ sessionId: "session_b" });
265
+ const [turn_a, turn_b] = await Promise.all([
266
+ session_a.prompt({ query: "Call your skill plugin" }),
267
+ session_b.prompt({ query: "Call your skill plugin" }),
268
+ ]);
269
+ const [result_a, result_b] = await Promise.all([
270
+ turn_a.finished,
271
+ turn_b.finished,
272
+ ]);
273
+
274
+ assert.equal(result_a.success, true);
275
+ assert.equal(result_b.success, true);
276
+ assert.deepEqual([...executed_owners].sort(), ["agent_a", "agent_b"]);
277
+ for (const [model, owner] of [
278
+ ["model_a", "agent_a"],
279
+ ["model_b", "agent_b"],
280
+ ]) {
281
+ const requests = model_requests.get(model) || [];
282
+ assert.equal(requests.length, 2);
283
+ assert.match(JSON.stringify(requests[1].messages), new RegExp(owner));
284
+ }
285
+ } finally {
286
+ await Promise.all([agent_a.dispose(), agent_b.dispose()]);
287
+ await new Promise((resolve) => server.close(() => resolve()));
288
+ }
289
+ });
@@ -313,3 +313,55 @@ test("PluginRegistry keeps plugin ready after action business failure", async ()
313
313
  assert.equal(retry.data.loaded, true);
314
314
  assert.equal(registry.status("skill").status, "ready");
315
315
  });
316
+
317
+ test("PluginRegistry delays lifecycle stop until the active execution lease is released", async () => {
318
+ let lifecycle_active = false;
319
+ let stop_count = 0;
320
+ const plugin = createPlugin({
321
+ name: "leased-plugin",
322
+ title: "Leased Plugin",
323
+ description: "Keeps runtime resources alive for an active Session step",
324
+ lifecycle: {
325
+ start: async () => {
326
+ lifecycle_active = true;
327
+ },
328
+ stop: async () => {
329
+ lifecycle_active = false;
330
+ stop_count += 1;
331
+ },
332
+ },
333
+ actions: {
334
+ status: createAction({
335
+ description: "Read lifecycle state",
336
+ execute: async () => ({
337
+ success: lifecycle_active,
338
+ data: { lifecycle_active },
339
+ }),
340
+ }),
341
+ },
342
+ });
343
+ const registry = createAgentPluginRegistry({
344
+ plugins: [plugin],
345
+ plugin_instances: new Map(),
346
+ get_context: () => ({ rootPath: process.cwd() }),
347
+ });
348
+ await registry.startAll();
349
+ const lease = registry.execution_view().acquire();
350
+
351
+ assert.equal(await registry.unregister("leased-plugin"), true);
352
+ assert.equal(registry.has("leased-plugin"), false);
353
+ assert.equal(lifecycle_active, true);
354
+ assert.equal(stop_count, 0);
355
+
356
+ const result = await lease.runAction({
357
+ plugin: "leased-plugin",
358
+ action: "status",
359
+ });
360
+ assert.equal(result.success, true);
361
+ assert.equal(result.data.lifecycle_active, true);
362
+
363
+ await lease.release();
364
+ await lease.release();
365
+ assert.equal(lifecycle_active, false);
366
+ assert.equal(stop_count, 1);
367
+ });
@@ -102,3 +102,21 @@ test("RemoteSession switches models by stable id without local model instances",
102
102
  /does not accept a local model instance/,
103
103
  );
104
104
  });
105
+
106
+ test("RemoteSession queues compact through its transport", async () => {
107
+ const compacted_session_ids = [];
108
+ const transport = {
109
+ async compact(session_id) {
110
+ compacted_session_ids.push(session_id);
111
+ },
112
+ };
113
+ const session = new RemoteSession(transport, {
114
+ agentId: "agent_test",
115
+ sessionId: "session_test",
116
+ messageCount: 0,
117
+ });
118
+
119
+ await session.compact();
120
+
121
+ assert.deepEqual(compacted_session_ids, ["session_test"]);
122
+ });
@@ -1,10 +1,10 @@
1
1
  /**
2
- * @file 验证 CityModel 上下文窗口会驱动默认 session compact 预算。
2
+ * @file 验证 CityModel 上下文窗口元数据与 compact 的明确触发语义。
3
3
  *
4
4
  * 关键点(中文)
5
5
  * - CityModel 目录元数据需要在归一化前读取。
6
- * - 默认 compact 使用模型总窗口的 80%,并随重试次数收紧。
7
- * - 显式 maxInputTokensApprox 始终优先于模型目录配置。
6
+ * - compact 不再根据字符/token 预估自动触发。
7
+ * - 只有明确 force 或超限恢复才会调用 history store。
8
8
  */
9
9
 
10
10
  import assert from "node:assert/strict";
@@ -53,6 +53,7 @@ async function capture_compact_input(options = {}) {
53
53
  model: {},
54
54
  system: [],
55
55
  retryCount: options.retry_count ?? 0,
56
+ force: true,
56
57
  ...(options.context_window !== undefined
57
58
  ? { context_window: options.context_window }
58
59
  : {}),
@@ -68,28 +69,28 @@ test("CityModel exposes its configured context window to Agent", () => {
68
69
  );
69
70
  })
70
71
 
71
- test("default compact budget uses 80 percent of the model context window", async () => {
72
- const first_attempt = await capture_compact_input({
73
- context_window: 256000,
74
- });
75
- assert.equal(first_attempt.maxInputTokensApprox, 204800);
76
-
77
- const retry = await capture_compact_input({
78
- context_window: 256000,
79
- retry_count: 1,
80
- });
81
- assert.equal(retry.maxInputTokensApprox, 102400);
82
- })
72
+ test("compact input carries only an explicit force request", async () => {
73
+ const compact_input = await capture_compact_input({ context_window: 256000 });
74
+ assert.equal(compact_input.force, true);
75
+ assert.equal("maxInputTokensApprox" in compact_input, false);
76
+ assert.equal("compactRatio" in compact_input, false);
77
+ assert.equal("keepLastMessages" in compact_input, false);
78
+ });
83
79
 
84
- test("explicit compact budget overrides the model context window", async () => {
85
- const compact_input = await capture_compact_input({
86
- context_window: 256000,
87
- composer_options: { maxInputTokensApprox: 64000 },
80
+ test("normal composer run does not call history store without force", async () => {
81
+ let called = false;
82
+ const composer = new JsonlSessionCompactionComposer();
83
+ const result = await composer.run({
84
+ historyStore: {
85
+ compact: async () => {
86
+ called = true;
87
+ return { compacted: true };
88
+ },
89
+ },
90
+ model: {},
91
+ system: [],
92
+ retryCount: 0,
88
93
  });
89
- assert.equal(compact_input.maxInputTokensApprox, 64000);
90
- })
91
-
92
- test("compact keeps the existing 128k fallback without model metadata", async () => {
93
- const compact_input = await capture_compact_input();
94
- assert.equal(compact_input.maxInputTokensApprox, 128000);
95
- })
94
+ assert.deepEqual(result, { compacted: false, reason: "not_requested" });
95
+ assert.equal(called, false);
96
+ });
@@ -1,10 +1,10 @@
1
1
  /**
2
- * @file 验证 Agent 配置在运行中修改后统一于下一模型 turn 生效。
2
+ * @file 验证 Agent 配置与 steer 在 Session step 检查点统一生效。
3
3
  *
4
4
  * 关键点(中文)
5
- * - 第一轮 provider 请求保持阻塞,用来制造真实的运行中配置修改窗口。
5
+ * - 第一次 provider 请求保持阻塞,用来制造真实的运行中配置修改窗口。
6
6
  * - instruction、env 与 plugin registry 修改不能改变已经开始的请求。
7
- * - 下一模型 turn 统一提交配置,并通过 Session action message 表达生效。
7
+ * - config steer 在同一个 Session step 检查点提交,并继续使用同一个 turn id。
8
8
  */
9
9
 
10
10
  import test from "node:test";
@@ -58,33 +58,39 @@ function create_stream_text_result(text) {
58
58
  };
59
59
  }
60
60
 
61
- test("running Agent config changes become effective together at the next model turn", async () => {
61
+ test("running Agent config changes and steer apply at the same Session step checkpoint", async () => {
62
62
  const agent_path = await fs.mkdtemp(
63
63
  path.join(os.tmpdir(), "downcity-agent-config-turn-boundary-"),
64
64
  );
65
- const first_model_turn_started = create_deferred();
66
- const release_first_model_turn = create_deferred();
65
+ const first_provider_request_started = create_deferred();
66
+ const release_first_provider_request = create_deferred();
67
67
  const provider_prompts = [];
68
- let provider_turn_count = 0;
68
+ let provider_request_count = 0;
69
+ let plugin_stop_count = 0;
69
70
 
70
71
  const model = new MockLanguageModelV3({
71
72
  modelId: "config-turn-boundary-model",
72
73
  doStream: async (options) => {
73
74
  const has_tools = Array.isArray(options.tools) && options.tools.length > 0;
74
75
  if (!has_tools) return create_stream_text_result("Session title");
75
- provider_turn_count += 1;
76
+ provider_request_count += 1;
76
77
  provider_prompts.push(JSON.stringify(options.prompt));
77
- if (provider_turn_count === 1) {
78
- first_model_turn_started.resolve();
79
- await release_first_model_turn.promise;
78
+ if (provider_request_count === 1) {
79
+ first_provider_request_started.resolve();
80
+ await release_first_provider_request.promise;
80
81
  }
81
- return create_stream_text_result(`done:${String(provider_turn_count)}`);
82
+ return create_stream_text_result(`done:${String(provider_request_count)}`);
82
83
  },
83
84
  });
84
85
  const runtime_plugin = createPlugin({
85
86
  name: "runtime-config",
86
87
  title: "Runtime Config",
87
88
  description: "Provides a system block for turn-boundary tests",
89
+ lifecycle: {
90
+ stop: async () => {
91
+ plugin_stop_count += 1;
92
+ },
93
+ },
88
94
  system: (context) => `plugin-env:${context.env.TURN_ENV || "missing"}`,
89
95
  actions: {
90
96
  ping: createAction({
@@ -107,22 +113,25 @@ test("running Agent config changes become effective together at the next model t
107
113
  sessionId: "config_turn_boundary_session",
108
114
  });
109
115
  const first_turn = await session.prompt({ query: "first" });
110
- await first_model_turn_started.promise;
116
+ await first_provider_request_started.promise;
111
117
 
112
118
  agent.setInstruction(["instruction:new"]);
113
119
  agent.patchEnv({ TURN_ENV: "new" });
114
120
  await agent.plugins.unregister("runtime-config");
121
+ const steer_turn_promise = session.prompt({ query: "steer" });
115
122
 
123
+ assert.equal(plugin_stop_count, 0);
116
124
  assert.equal(provider_prompts.length, 1);
117
125
  assert.match(provider_prompts[0], /instruction:old/);
118
126
  assert.match(provider_prompts[0], /plugin-env:old/);
119
127
  assert.doesNotMatch(provider_prompts[0], /instruction:new/);
120
128
 
121
- release_first_model_turn.resolve();
129
+ release_first_provider_request.resolve();
130
+ const steer_turn = await steer_turn_promise;
122
131
  assert.equal((await first_turn.finished).success, true);
123
-
124
- const second_turn = await session.prompt({ query: "second" });
125
- assert.equal((await second_turn.finished).success, true);
132
+ assert.equal(steer_turn.id, first_turn.id);
133
+ assert.equal((await steer_turn.finished).success, true);
134
+ assert.equal(plugin_stop_count, 1);
126
135
  assert.equal(provider_prompts.length, 2);
127
136
  assert.match(provider_prompts[1], /instruction:new/);
128
137
  assert.doesNotMatch(provider_prompts[1], /instruction:old/);
@@ -138,14 +147,14 @@ test("running Agent config changes become effective together at the next model t
138
147
  "Agent plugin runtime-config unregistered",
139
148
  ]);
140
149
  } finally {
141
- release_first_model_turn.resolve();
150
+ release_first_provider_request.resolve();
142
151
  await agent.dispose();
143
152
  }
144
153
  });
145
154
 
146
- test("running session model changes keep the current provider and switch the next model turn", async () => {
155
+ test("running session model changes apply with steer at the next Session step", async () => {
147
156
  const agent_path = await fs.mkdtemp(
148
- path.join(os.tmpdir(), "downcity-session-model-turn-boundary-"),
157
+ path.join(os.tmpdir(), "downcity-session-step-boundary-"),
149
158
  );
150
159
  const old_model_started = create_deferred();
151
160
  const release_old_model = create_deferred();
@@ -183,7 +192,7 @@ test("running session model changes keep the current provider and switch the nex
183
192
  },
184
193
  });
185
194
  const agent = new Agent({
186
- id: "session_model_turn_boundary_agent",
195
+ id: "session_step_boundary_agent",
187
196
  path: agent_path,
188
197
  model: old_model,
189
198
  plugins: [runtime_plugin],
@@ -191,20 +200,20 @@ test("running session model changes keep the current provider and switch the nex
191
200
 
192
201
  try {
193
202
  const session = await agent.sessions.create({
194
- sessionId: "session_model_turn_boundary_session",
203
+ sessionId: "session_step_boundary_session",
195
204
  });
196
205
  const first_turn = await session.prompt({ query: "first" });
197
206
  await old_model_started.promise;
198
207
 
199
208
  await session.set({ model: new_model });
209
+ const steer_turn_promise = session.prompt({ query: "continue" });
200
210
  assert.deepEqual(model_calls, ["old-model"]);
201
211
 
202
212
  release_old_model.resolve();
213
+ const steer_turn = await steer_turn_promise;
203
214
  assert.equal((await first_turn.finished).success, true);
204
- assert.deepEqual(model_calls, ["old-model"]);
205
-
206
- const second_turn = await session.prompt({ query: "second" });
207
- assert.equal((await second_turn.finished).success, true);
215
+ assert.equal(steer_turn.id, first_turn.id);
216
+ assert.equal((await steer_turn.finished).success, true);
208
217
  assert.deepEqual(model_calls, ["old-model", "new-model"]);
209
218
 
210
219
  const messages = await session.messages();
@@ -222,3 +231,48 @@ test("running session model changes keep the current provider and switch the nex
222
231
  await agent.dispose();
223
232
  }
224
233
  });
234
+
235
+ test("config remains effective when its action message cannot be persisted", async () => {
236
+ const agent_path = await fs.mkdtemp(
237
+ path.join(os.tmpdir(), "downcity-config-action-observability-"),
238
+ );
239
+ const model_calls = [];
240
+ const old_model = new MockLanguageModelV3({
241
+ modelId: "old-observability-model",
242
+ doStream: async () => {
243
+ model_calls.push("old");
244
+ return create_stream_text_result("old");
245
+ },
246
+ });
247
+ const new_model = new MockLanguageModelV3({
248
+ modelId: "new-observability-model",
249
+ doStream: async () => {
250
+ model_calls.push("new");
251
+ return create_stream_text_result("new");
252
+ },
253
+ });
254
+ const agent = new Agent({
255
+ id: "config_action_observability_agent",
256
+ path: agent_path,
257
+ model: old_model,
258
+ });
259
+
260
+ try {
261
+ const session = await agent.sessions.create({
262
+ sessionId: "config_action_observability_session",
263
+ });
264
+ session.stateService.emit_action_event = async () => {
265
+ throw new Error("action store unavailable");
266
+ };
267
+
268
+ await session.set({ model: new_model });
269
+ const turn = await session.prompt({ query: "use configured model" });
270
+ assert.equal((await turn.finished).success, true);
271
+ assert.deepEqual(model_calls, ["new", "new"]);
272
+
273
+ const messages = await session.messages();
274
+ assert.equal(messages.items.some((message) => message.type === "action"), false);
275
+ } finally {
276
+ await agent.dispose();
277
+ }
278
+ });