@downcity/agent 1.1.445 → 1.5.0

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 (122) hide show
  1. package/bin/agent/Agent.d.ts +29 -2
  2. package/bin/agent/Agent.d.ts.map +1 -1
  3. package/bin/agent/Agent.js +56 -10
  4. package/bin/agent/Agent.js.map +1 -1
  5. package/bin/agent/AgentSessionIdentity.d.ts +19 -0
  6. package/bin/agent/AgentSessionIdentity.d.ts.map +1 -0
  7. package/bin/agent/AgentSessionIdentity.js +26 -0
  8. package/bin/agent/AgentSessionIdentity.js.map +1 -0
  9. package/bin/agent/AgentSessions.d.ts +35 -9
  10. package/bin/agent/AgentSessions.d.ts.map +1 -1
  11. package/bin/agent/AgentSessions.js +110 -63
  12. package/bin/agent/AgentSessions.js.map +1 -1
  13. package/bin/agent/SessionWork.d.ts +85 -0
  14. package/bin/agent/SessionWork.d.ts.map +1 -0
  15. package/bin/agent/SessionWork.js +165 -0
  16. package/bin/agent/SessionWork.js.map +1 -0
  17. package/bin/index.d.ts +5 -0
  18. package/bin/index.d.ts.map +1 -1
  19. package/bin/index.js.map +1 -1
  20. package/bin/session/Session.d.ts +34 -8
  21. package/bin/session/Session.d.ts.map +1 -1
  22. package/bin/session/Session.js +102 -27
  23. package/bin/session/Session.js.map +1 -1
  24. package/bin/session/input/StepInput.d.ts +15 -0
  25. package/bin/session/input/StepInput.d.ts.map +1 -1
  26. package/bin/session/input/StepInput.js +39 -5
  27. package/bin/session/input/StepInput.js.map +1 -1
  28. package/bin/session/loop/SessionLoop.d.ts +2 -1
  29. package/bin/session/loop/SessionLoop.d.ts.map +1 -1
  30. package/bin/session/loop/SessionLoop.js +7 -9
  31. package/bin/session/loop/SessionLoop.js.map +1 -1
  32. package/bin/session/loop/SessionState.d.ts +21 -1
  33. package/bin/session/loop/SessionState.d.ts.map +1 -1
  34. package/bin/session/loop/SessionState.js +54 -1
  35. package/bin/session/loop/SessionState.js.map +1 -1
  36. package/bin/session/storage/Browse.d.ts.map +1 -1
  37. package/bin/session/storage/Browse.js +2 -2
  38. package/bin/session/storage/Browse.js.map +1 -1
  39. package/bin/session/storage/LocalSessionStore.d.ts.map +1 -1
  40. package/bin/session/storage/LocalSessionStore.js +7 -7
  41. package/bin/session/storage/LocalSessionStore.js.map +1 -1
  42. package/bin/tools/session/SessionToolSchemas.d.ts +34 -0
  43. package/bin/tools/session/SessionToolSchemas.d.ts.map +1 -0
  44. package/bin/tools/session/SessionToolSchemas.js +39 -0
  45. package/bin/tools/session/SessionToolSchemas.js.map +1 -0
  46. package/bin/tools/session/SessionTools.d.ts +57 -0
  47. package/bin/tools/session/SessionTools.d.ts.map +1 -0
  48. package/bin/tools/session/SessionTools.js +111 -0
  49. package/bin/tools/session/SessionTools.js.map +1 -0
  50. package/bin/types/agent/AgentOptions.d.ts +10 -1
  51. package/bin/types/agent/AgentOptions.d.ts.map +1 -1
  52. package/bin/types/agent/AgentSessionCollection.d.ts +17 -6
  53. package/bin/types/agent/AgentSessionCollection.d.ts.map +1 -1
  54. package/bin/types/agent/AgentSessionCollection.js +1 -1
  55. package/bin/types/agent/AgentSessionInput.d.ts +14 -0
  56. package/bin/types/agent/AgentSessionInput.d.ts.map +1 -0
  57. package/bin/types/agent/AgentSessionInput.js +9 -0
  58. package/bin/types/agent/AgentSessionInput.js.map +1 -0
  59. package/bin/types/agent/SessionActor.d.ts +16 -5
  60. package/bin/types/agent/SessionActor.d.ts.map +1 -1
  61. package/bin/types/agent/SessionContext.d.ts +26 -0
  62. package/bin/types/agent/SessionContext.d.ts.map +1 -0
  63. package/bin/types/agent/SessionContext.js +12 -0
  64. package/bin/types/agent/SessionContext.js.map +1 -0
  65. package/bin/types/agent/SessionState.d.ts +55 -0
  66. package/bin/types/agent/SessionState.d.ts.map +1 -0
  67. package/bin/types/agent/SessionState.js +15 -0
  68. package/bin/types/agent/SessionState.js.map +1 -0
  69. package/bin/types/agent/SessionTypes.d.ts +12 -1
  70. package/bin/types/agent/SessionTypes.d.ts.map +1 -1
  71. package/bin/types/agent/SessionWork.d.ts +53 -0
  72. package/bin/types/agent/SessionWork.d.ts.map +1 -0
  73. package/bin/types/agent/SessionWork.js +13 -0
  74. package/bin/types/agent/SessionWork.js.map +1 -0
  75. package/bin/types/session/SessionLocalState.d.ts +20 -1
  76. package/bin/types/session/SessionLocalState.d.ts.map +1 -1
  77. package/bin/types/session/SessionLoop.d.ts +6 -2
  78. package/bin/types/session/SessionLoop.d.ts.map +1 -1
  79. package/bin/types/session/SessionOptions.d.ts +9 -11
  80. package/bin/types/session/SessionOptions.d.ts.map +1 -1
  81. package/bin/types/session/StepInput.d.ts +5 -5
  82. package/bin/types/session/StepInput.d.ts.map +1 -1
  83. package/bin/types/tools/session/SessionTools.d.ts +92 -0
  84. package/bin/types/tools/session/SessionTools.d.ts.map +1 -0
  85. package/bin/types/tools/session/SessionTools.js +12 -0
  86. package/bin/types/tools/session/SessionTools.js.map +1 -0
  87. package/package.json +2 -2
  88. package/scripts/session-executor-context-pressure.test.mjs +1 -1
  89. package/scripts/session-executor-failure-result.test.mjs +1 -1
  90. package/scripts/session-executor-max-steps.test.mjs +1 -1
  91. package/scripts/session-initialization.test.mjs +4 -2
  92. package/scripts/session-interaction-migration.test.mjs +2 -2
  93. package/scripts/session-prompt-idempotency.test.mjs +2 -2
  94. package/scripts/session-storage-sqlite.test.mjs +3 -3
  95. package/scripts/session-turn-context.test.mjs +4 -4
  96. package/src/agent/Agent.ts +64 -10
  97. package/src/agent/AgentSessionIdentity.ts +30 -0
  98. package/src/agent/AgentSessions.ts +119 -77
  99. package/src/agent/SessionWork.ts +235 -0
  100. package/src/index.ts +19 -0
  101. package/src/session/Session.ts +112 -29
  102. package/src/session/input/StepInput.ts +42 -7
  103. package/src/session/loop/SessionLoop.ts +7 -9
  104. package/src/session/loop/SessionState.ts +61 -1
  105. package/src/session/storage/Browse.ts +2 -1
  106. package/src/session/storage/LocalSessionStore.ts +7 -7
  107. package/src/tools/session/SessionToolSchemas.ts +61 -0
  108. package/src/tools/session/SessionTools.ts +170 -0
  109. package/src/types/agent/AgentOptions.ts +12 -1
  110. package/src/types/agent/AgentSessionCollection.ts +18 -7
  111. package/src/types/agent/AgentSessionInput.ts +15 -0
  112. package/src/types/agent/SessionActor.ts +23 -5
  113. package/src/types/agent/SessionContext.ts +29 -0
  114. package/src/types/agent/SessionState.ts +74 -0
  115. package/src/types/agent/SessionTypes.ts +13 -1
  116. package/src/types/agent/SessionWork.ts +57 -0
  117. package/src/types/session/SessionLocalState.ts +24 -1
  118. package/src/types/session/SessionLoop.ts +6 -2
  119. package/src/types/session/SessionOptions.ts +10 -14
  120. package/src/types/session/StepInput.ts +5 -5
  121. package/src/types/tools/session/SessionTools.ts +104 -0
  122. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,235 @@
1
+ /**
2
+ * SessionWork:Agent 会话安排与观察其他会话的实现。
3
+ *
4
+ * 职责说明(中文)
5
+ * - 把 AgentSessions 的能力投影成「一件活」的完整动作:开、看、答、停。
6
+ * - 只读取公开的摘要与消息投影,不缓存会话,也不持有存储。
7
+ *
8
+ * 关键点(中文)
9
+ * - 清单排除 Agent 自己的会话,否则清单里会出现「自己」。
10
+ * - `spawn` 不等待 Turn 结束:派活的意义就是 Agent 会话立刻能继续。
11
+ * - 详情按最近消息分页读取,让对话能回答「它做了什么」,而不把整段历史塞进上下文。
12
+ */
13
+
14
+ import type { SessionInteractionResponse, WorkspaceRuntime } from "@downcity/type";
15
+ import { is_agent_session_origin } from "@downcity/type";
16
+ import type { SessionWorkContract } from "@/types/agent/SessionWork.js";
17
+ import type {
18
+ SessionCheckDetailToolOutput,
19
+ SessionToolState,
20
+ SessionToolSummary,
21
+ } from "@/types/tools/session/SessionTools.js";
22
+ import type { AgentSession } from "@/types/agent/SessionActor.js";
23
+
24
+ /** 清单默认返回的会话数量上限。 */
25
+ const DEFAULT_SESSION_LIMIT = 20;
26
+
27
+ /** 详情默认返回的最近消息条数。 */
28
+ const DEFAULT_MESSAGE_LIMIT = 20;
29
+
30
+ /** 清单中的一条会话列表项;由 AgentSessions 摘要投影而来。 */
31
+ export interface SessionWorkListEntry {
32
+ /** 会话稳定标识。 */
33
+ session_id: string;
34
+ /** 用户可见标题;未生成时省略。 */
35
+ title?: string;
36
+ /** 最近更新的预览文本。 */
37
+ preview_text?: string;
38
+ /** 最近更新时间戳(ms)。 */
39
+ updated_at?: number;
40
+ /** 会话来源。 */
41
+ origin: { type: string };
42
+ /** 当前是否正在执行。 */
43
+ executing?: boolean;
44
+ }
45
+
46
+ /** SessionWork 构造参数。 */
47
+ export interface SessionWorkOptions {
48
+ /** 列出活动会话摘要,`limit` 由本模块归一化后传入。 */
49
+ list_sessions: (input: { limit: number }) => Promise<{
50
+ items: SessionWorkListEntry[];
51
+ }>;
52
+ /** 恢复单条会话的运行时实例。 */
53
+ get_session: (session_id: string, origin_type: string) => Promise<AgentSession>;
54
+ /** 新建一条属于当前 Agent 的会话。 */
55
+ create_session: (input?: { workspace?: WorkspaceRuntime }) => Promise<AgentSession>;
56
+ }
57
+
58
+ /** Agent 会话的会话工作运行时。 */
59
+ export class SessionWork implements SessionWorkContract {
60
+ private readonly options: SessionWorkOptions;
61
+
62
+ constructor(options: SessionWorkOptions) {
63
+ this.options = options;
64
+ }
65
+
66
+ /** 开一条工作会话并立刻把任务交给它。 */
67
+ async spawn(input: {
68
+ query: string;
69
+ title?: string;
70
+ workspace?: WorkspaceRuntime;
71
+ }): Promise<{ session_id: string }> {
72
+ const query = String(input.query || "").trim();
73
+ if (!query) throw new Error("session_start requires a non-empty query");
74
+ const session = await this.options.create_session(
75
+ input.workspace ? { workspace: input.workspace } : undefined,
76
+ );
77
+ const title = String(input.title || "").trim();
78
+ if (title) await session.rename(title);
79
+ // 不等待 Turn 结束:派活的意义就是 Agent 会话立刻能继续。
80
+ void session.prompt({ query }).catch(() => undefined);
81
+ return { session_id: session.id };
82
+ }
83
+
84
+ /** 列出当前 Agent 除自己那条之外的全部会话。 */
85
+ async list(input?: { limit?: number }): Promise<SessionToolSummary[]> {
86
+ const page = await this.options.list_sessions({
87
+ limit: normalize_limit(input?.limit, DEFAULT_SESSION_LIMIT),
88
+ });
89
+ const summaries: SessionToolSummary[] = [];
90
+ for (const item of page.items) {
91
+ if (is_agent_session_origin(item.origin)) continue;
92
+ const pending_interaction_count = await this.count_pending(item);
93
+ summaries.push({
94
+ session_id: item.session_id,
95
+ ...(item.title ? { title: item.title } : {}),
96
+ state: resolve_session_state({
97
+ executing: item.executing === true,
98
+ pending_interaction_count,
99
+ }),
100
+ ...(item.preview_text ? { preview_text: item.preview_text } : {}),
101
+ ...(typeof item.updated_at === "number" ? { updated_at: item.updated_at } : {}),
102
+ pending_interaction_count,
103
+ });
104
+ }
105
+ return summaries;
106
+ }
107
+
108
+ /** 读取单条会话的摘要、最近消息与待响应 Interaction。 */
109
+ async read(input: {
110
+ session_id: string;
111
+ limit?: number;
112
+ }): Promise<SessionCheckDetailToolOutput> {
113
+ const item = await this.require_list_entry(input.session_id);
114
+ const session = await this.options.get_session(item.session_id, item.origin.type);
115
+ const [messages, pending, status] = await Promise.all([
116
+ session.messages({ limit: normalize_limit(input.limit, DEFAULT_MESSAGE_LIMIT) }),
117
+ session.interactions(),
118
+ session.status(),
119
+ ]);
120
+ const failure = resolve_failure_text(messages.items);
121
+ return {
122
+ mode: "detail",
123
+ session: {
124
+ session_id: item.session_id,
125
+ ...(item.title ? { title: item.title } : {}),
126
+ state: resolve_session_state({
127
+ executing: status.state === "running",
128
+ pending_interaction_count: pending.length,
129
+ }),
130
+ ...(item.preview_text ? { preview_text: item.preview_text } : {}),
131
+ ...(typeof item.updated_at === "number" ? { updated_at: item.updated_at } : {}),
132
+ pending_interaction_count: pending.length,
133
+ },
134
+ messages: messages.items
135
+ .map((message) => project_message_text(message))
136
+ .filter((entry): entry is { role: "user" | "agent"; text: string } => Boolean(entry)),
137
+ pending_interactions: pending.map((interaction) => ({
138
+ interaction_id: interaction.interaction_id,
139
+ type: interaction.type,
140
+ ...(interaction.title ? { title: interaction.title } : {}),
141
+ ...(interaction.description ? { description: interaction.description } : {}),
142
+ })),
143
+ ...(failure ? { failure } : {}),
144
+ };
145
+ }
146
+
147
+ /** 代答某条会话正在等待的用户响应。 */
148
+ async reply(input: {
149
+ session_id: string;
150
+ interaction_id: string;
151
+ response: SessionInteractionResponse;
152
+ }): Promise<void> {
153
+ const item = await this.require_list_entry(input.session_id);
154
+ const session = await this.options.get_session(item.session_id, item.origin.type);
155
+ const pending = await session.interactions();
156
+ if (!pending.some((entry) => entry.interaction_id === input.interaction_id)) {
157
+ throw new Error(
158
+ `Interaction is not pending on session "${input.session_id}": ${input.interaction_id}`,
159
+ );
160
+ }
161
+ await session.respond({
162
+ interaction_id: input.interaction_id,
163
+ response: input.response,
164
+ });
165
+ }
166
+
167
+ /** 停止某条会话当前正在执行的 Turn。 */
168
+ async stop(input: { session_id: string }): Promise<void> {
169
+ const item = await this.require_list_entry(input.session_id);
170
+ const session = await this.options.get_session(item.session_id, item.origin.type);
171
+ await session.stop();
172
+ }
173
+
174
+ /** 按标识取出属于当前 Agent 的一条会话;不存在时明确失败。 */
175
+ private async require_list_entry(session_id_input: string): Promise<SessionWorkListEntry> {
176
+ const session_id = String(session_id_input || "").trim();
177
+ if (!session_id) throw new Error("Session id is required");
178
+ const page = await this.options.list_sessions({ limit: Number.MAX_SAFE_INTEGER });
179
+ const item = page.items.find((candidate) => candidate.session_id === session_id);
180
+ if (!item) throw new Error(`Session is not owned by this agent: ${session_id}`);
181
+ return item;
182
+ }
183
+
184
+ /** 读取一条会话当前待响应的 Interaction 数量;读取失败时按零处理。 */
185
+ private async count_pending(item: SessionWorkListEntry): Promise<number> {
186
+ try {
187
+ const session = await this.options.get_session(item.session_id, item.origin.type);
188
+ return (await session.interactions()).length;
189
+ } catch {
190
+ return 0;
191
+ }
192
+ }
193
+ }
194
+
195
+ /** 归一化可选数量上限;非法值回退到默认值。 */
196
+ function normalize_limit(input: number | undefined, fallback: number): number {
197
+ return Number.isInteger(input) && (input as number) > 0 ? (input as number) : fallback;
198
+ }
199
+
200
+ /** 由执行态与待响应数量推导清单状态。 */
201
+ function resolve_session_state(input: {
202
+ /** 该会话当前是否在执行 Turn。 */
203
+ executing: boolean;
204
+ /** 当前待用户响应的 Interaction 数量。 */
205
+ pending_interaction_count: number;
206
+ }): SessionToolState {
207
+ if (input.pending_interaction_count > 0) return "waiting_for_user";
208
+ if (input.executing) return "running";
209
+ return "idle";
210
+ }
211
+
212
+ /** 把 canonical Message 投影为纯文本;没有可见文本时返回空。 */
213
+ function project_message_text(message: {
214
+ role: string;
215
+ parts: readonly { type: string; text?: string }[];
216
+ }): { role: "user" | "agent"; text: string } | null {
217
+ if (message.role !== "user" && message.role !== "agent") return null;
218
+ const text = message.parts
219
+ .flatMap((part) => (typeof part.text === "string" ? [part.text] : []))
220
+ .join("\n")
221
+ .trim();
222
+ return text ? { role: message.role, text } : null;
223
+ }
224
+
225
+ /** 读取最近一条 Agent 消息上的失败文本;没有失败时返回空。 */
226
+ function resolve_failure_text(
227
+ messages: readonly {
228
+ role: string;
229
+ parts: readonly { type: string; message?: string }[];
230
+ }[],
231
+ ): string | undefined {
232
+ const latest_agent = [...messages].reverse().find((message) => message.role === "agent");
233
+ const error = latest_agent?.parts.find((part) => part.type === "error");
234
+ return error?.message ? error.message : undefined;
235
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,25 @@
9
9
 
10
10
  // Agent 入口
11
11
  export { Agent } from "./agent/Agent.js";
12
+ export type { AgentSessionInput } from "./types/agent/AgentSessionInput.js";
13
+ export type { SessionWorkContract } from "./types/agent/SessionWork.js";
14
+ export type { SessionContextResolution } from "./types/agent/SessionContext.js";
15
+ export type {
16
+ AgentSessionStateKey,
17
+ AgentSessionStateValues,
18
+ RemoteSessionStateKey,
19
+ RemoteSessionStateValues,
20
+ } from "./types/agent/SessionState.js";
21
+ export type {
22
+ SessionCheckDetailToolOutput,
23
+ SessionCheckListToolOutput,
24
+ SessionCheckToolOutput,
25
+ SessionReplyToolOutput,
26
+ SessionStartToolOutput,
27
+ SessionStopToolOutput,
28
+ SessionToolState,
29
+ SessionToolSummary,
30
+ } from "./types/tools/session/SessionTools.js";
12
31
  export { Group } from "./group/Group.js";
13
32
  export type {
14
33
  DispatchAssignment,
@@ -45,8 +45,12 @@ import {
45
45
  import { SessionLoop } from "@/session/loop/SessionLoop.js";
46
46
  import { SessionQueue } from "@/session/loop/SessionQueue.js";
47
47
  import type { SessionLocalState } from "@/types/session/SessionLocalState.js";
48
+ import type {
49
+ AgentSessionStateKey,
50
+ AgentSessionStateValues,
51
+ } from "@/types/agent/SessionState.js";
48
52
  import type { SessionOptions } from "@/types/session/SessionOptions.js";
49
- import type { ToolCallContext, ToolHookSet } from "@downcity/type";
53
+ import type { ToolCallContext, ToolHookSet, WorkspaceRuntime } from "@downcity/type";
50
54
  import { SessionInteractions } from "@/session/messages/SessionInteractions.js";
51
55
  import { DefaultSessionComposer } from "@/session/input/composer/DefaultSessionComposer.js";
52
56
  import type { SessionComposer } from "@/types/session/SessionComposer.js";
@@ -76,12 +80,15 @@ export class Session implements AgentSession {
76
80
  readonly agent_name: string;
77
81
  /** 当前 Agent 一句话能力描述;进入工具与扩展的调用环境。 */
78
82
  readonly agent_description: string;
79
- /** 当前 Session 创建时绑定的 Workspace ID。 */
80
- readonly workspace_id?: string;
83
+ /**
84
+ * 当前 Session 正在使用的 Workspace ID。
85
+ *
86
+ * 关键点(中文):这是可变的会话状态,不是身份;切换目录后随之改变。
87
+ */
88
+ workspace_id?: string;
81
89
  /** 当前 Session 的创建来源。 */
82
90
  readonly origin: SessionOptions["origin"];
83
91
 
84
- private readonly workspace_path: string;
85
92
  private readonly store: SessionStorage;
86
93
  private readonly create_session_store: SessionOptions["create_session_store"];
87
94
  private readonly register_forked_session: SessionOptions["register_forked_session"];
@@ -94,9 +101,6 @@ export class Session implements AgentSession {
94
101
  /** 当前执行面生效的审批模式;在 Step 检查点从 configured 同步。 */
95
102
  private effective_approval_mode: SessionApprovalMode = "ask";
96
103
  private readonly local_state: SessionLocalState;
97
- private readonly get_workspace_env: SessionOptions["get_workspace_env"];
98
- /** 读取当前 Session 绑定的 Workspace 实例。 */
99
- private readonly get_workspace: SessionOptions["get_workspace"];
100
104
  private readonly get_agent_model: SessionOptions["get_agent_model"];
101
105
  private readonly get_hooks: SessionOptions["get_hooks"];
102
106
  private readonly get_instruction_system_blocks:
@@ -116,16 +120,13 @@ export class Session implements AgentSession {
116
120
  this.agent_id = String(options.agent_id || "").trim();
117
121
  this.agent_name = String(options.agent_name || "").trim() || this.agent_id;
118
122
  this.agent_description = String(options.agent_description || "").trim();
119
- this.workspace_id = String(options.workspace_id || "").trim() || undefined;
123
+ this.workspace_id = String(options.workspace?.id || "").trim() || undefined;
120
124
  this.origin = options.origin;
121
- this.workspace_path = String(options.workspace_path || "").trim();
122
125
  this.store = options.store;
123
126
  this.create_session_store = options.create_session_store;
124
127
  this.register_forked_session = options.register_forked_session;
125
128
  this.get_tools = options.get_tools;
126
129
  this.logger = options.logger;
127
- this.get_workspace_env = options.get_workspace_env;
128
- this.get_workspace = options.get_workspace;
129
130
  this.get_agent_model = options.get_agent_model;
130
131
  this.get_hooks = options.get_hooks;
131
132
  this.get_instruction_system_blocks = options.get_instruction_system_blocks;
@@ -136,7 +137,7 @@ export class Session implements AgentSession {
136
137
  if (!this.agent_id) {
137
138
  throw new Error("Session requires a non-empty agent_id");
138
139
  }
139
- if (!this.workspace_path) {
140
+ if (!String(options.workspace_path || "").trim()) {
140
141
  throw new Error("Session requires a non-empty workspace_path");
141
142
  }
142
143
 
@@ -155,22 +156,24 @@ export class Session implements AgentSession {
155
156
  // 审批入口读执行面当前模式;configured 与 effective 的区别保留。
156
157
  read_approval_mode: () => this.effective_approval_mode,
157
158
  });
158
- this.local_state = create_session_local_state();
159
+ this.local_state = create_session_local_state({
160
+ ...(options.workspace ? { workspace: options.workspace } : {}),
161
+ fallback_workspace_path: String(options.workspace_path || "").trim(),
162
+ });
159
163
  this.step_input = new StepInput({
160
164
  agent_id: this.agent_id,
161
165
  agent_name: this.agent_name,
162
166
  agent_description: this.agent_description,
163
167
  session_id: this.id,
164
168
  session_origin: this.origin,
165
- workspace_path: this.workspace_path,
169
+ get_workspace_path: () => this.get_workspace_path(),
166
170
  store: this.store,
167
171
  composer: this.composer,
168
172
  get_tools: this.get_tools,
169
173
  instruction_system_blocks: options.instruction_system_blocks,
170
174
  get_instruction_system_blocks: this.get_instruction_system_blocks,
171
175
  get_hooks: this.get_hooks,
172
- get_workspace_env: this.get_workspace_env,
173
- get_workspace: this.get_workspace,
176
+ get_workspace: () => this.local_state.current_workspace,
174
177
  get_model: () => this.get_model(),
175
178
  get_model_context_window: () => this.get_model_context_window(),
176
179
  get_created_at: () => this.local_state.created_at,
@@ -192,7 +195,7 @@ export class Session implements AgentSession {
192
195
  this.session_loop = new SessionLoop({
193
196
  session_id: this.id,
194
197
  session_origin: this.origin,
195
- workspace_path: this.workspace_path,
198
+ get_workspace_path: () => this.get_workspace_path(),
196
199
  step_input: this.step_input,
197
200
  advance_context: async (trigger) => await this.advance_context(trigger),
198
201
  maintain_context: async () => {
@@ -260,9 +263,45 @@ export class Session implements AgentSession {
260
263
 
261
264
  /**
262
265
  * 读取当前 session 配置快照。
266
+ *
267
+ * 删除说明:它与 `get("model")` / `get("model_label")` 重复;
268
+ * 读取统一走 `get(key)`,不再保留属性形式的入口。
269
+ */
270
+
271
+ /**
272
+ * 读取当前 Session 状态。
273
+ *
274
+ * 关键点(中文)
275
+ * - 持久化字段与运行时实例共用这一个入口;键名即语义。
276
+ * - 只读,不产生副作用:不解析、不落盘、不发布 mutation。
263
277
  */
264
- get config(): AgentSessionConfigSnapshot {
265
- return this.state.get_config();
278
+ get<K extends AgentSessionStateKey>(key: K): AgentSessionStateValues[K] {
279
+ switch (key) {
280
+ case "workspace":
281
+ return this.local_state.current_workspace as AgentSessionStateValues[K];
282
+ case "workspace_id":
283
+ return this.workspace_id as AgentSessionStateValues[K];
284
+ case "model":
285
+ return this.get_selected_model() as AgentSessionStateValues[K];
286
+ case "model_label":
287
+ return this.state.get_config().model_label as AgentSessionStateValues[K];
288
+ case "model_context_window":
289
+ return this.state.get_config().model_context_window as AgentSessionStateValues[K];
290
+ case "approval_mode":
291
+ return this.state.get_approval_mode() as AgentSessionStateValues[K];
292
+ case "title":
293
+ return this.local_state.title as AgentSessionStateValues[K];
294
+ case "timezone":
295
+ return this.local_state.timezone as AgentSessionStateValues[K];
296
+ case "created_at":
297
+ return this.local_state.created_at as AgentSessionStateValues[K];
298
+ case "updated_at":
299
+ return this.local_state.updated_at as AgentSessionStateValues[K];
300
+ default: {
301
+ const unreachable: never = key;
302
+ throw new Error(`Unsupported session state key: ${String(unreachable)}`);
303
+ }
304
+ }
266
305
  }
267
306
 
268
307
  /**
@@ -272,8 +311,8 @@ export class Session implements AgentSession {
272
311
  input: AgentSessionSetInput,
273
312
  options?: AgentSessionSetOptions,
274
313
  ): Promise<void> {
275
- if (!input.model && !input.security) {
276
- throw new Error("session.set requires model or security");
314
+ if (!input.model && !input.security && input.workspace === undefined) {
315
+ throw new Error("session.set requires model, security, or workspace");
277
316
  }
278
317
  const requested_approval_mode = input.security?.approval_mode;
279
318
  if (
@@ -296,11 +335,28 @@ export class Session implements AgentSession {
296
335
  const model_result = input.model
297
336
  ? await this.state.set_model(input.model)
298
337
  : undefined;
338
+ const workspace_changed = input.workspace !== undefined
339
+ ? await this.state.apply_workspace(input.workspace ?? undefined)
340
+ : false;
341
+ const next_workspace = this.local_state.current_workspace;
299
342
  const next_approval_mode = requested_approval_mode;
300
343
  const security_changed = next_approval_mode !== undefined
301
344
  ? await this.state.set_approval_mode(next_approval_mode)
302
345
  : false;
303
- if (!model_result && !security_changed) return;
346
+ if (!model_result && !security_changed && !workspace_changed) return;
347
+ if (workspace_changed) {
348
+ this.workspace_id = next_workspace?.id;
349
+ }
350
+ if (workspace_changed && publish_mutation) {
351
+ this.events.publish({
352
+ mutation_id: generate_id(),
353
+ variant: "session",
354
+ type: "workspace",
355
+ session_id: this.id,
356
+ created_at: Date.now(),
357
+ ...(next_workspace ? { workspace_id: next_workspace.id } : {}),
358
+ });
359
+ }
304
360
  if (model_result?.changed && publish_mutation) {
305
361
  this.events.publish({
306
362
  mutation_id: generate_id(),
@@ -320,6 +376,9 @@ export class Session implements AgentSession {
320
376
  ...(model_result?.changed
321
377
  ? [`model: ${String(model_result.config.model_label || "configured")}`]
322
378
  : []),
379
+ ...(workspace_changed
380
+ ? [`workspace: ${String(next_workspace?.id || "none")}`]
381
+ : []),
323
382
  ...(security_changed
324
383
  ? [`security.approval_mode: ${String(next_approval_mode)}`]
325
384
  : []),
@@ -431,7 +490,7 @@ export class Session implements AgentSession {
431
490
  "",
432
491
  ).trim();
433
492
  return build_session_info({
434
- project_root: this.workspace_path,
493
+ project_root: this.get_workspace_path(),
435
494
  agent_id: this.agent_id,
436
495
  session_id: this.id,
437
496
  metadata: {
@@ -449,6 +508,20 @@ export class Session implements AgentSession {
449
508
  return await this.session_messages.list_messages(input);
450
509
  }
451
510
 
511
+ /**
512
+ * 返回当前目录;没有项目上下文时为空。
513
+ *
514
+ * 关键点(中文):只读读取。修改走 `set({ workspace })`,与 `model` 同一手柄。
515
+ */
516
+ get_workspace(): WorkspaceRuntime | undefined {
517
+ return this.local_state.current_workspace;
518
+ }
519
+
520
+ /** 返回当前目录路径;没有 Workspace 时使用回退值。 */
521
+ private get_workspace_path(): string {
522
+ return this.state.get_workspace_path();
523
+ }
524
+
452
525
  /**
453
526
  * 读取当前 session 生效的 system 快照。
454
527
  */
@@ -552,7 +625,7 @@ export class Session implements AgentSession {
552
625
  /** 当前 Turn 的取消信号。 */
553
626
  readonly abort_signal: AbortSignal;
554
627
  }): ToolCallContext {
555
- const workspace = this.get_workspace();
628
+ const workspace = this.local_state.current_workspace;
556
629
  return Object.freeze({
557
630
  agent_id: this.agent_id,
558
631
  agent_name: this.agent_name,
@@ -566,17 +639,28 @@ export class Session implements AgentSession {
566
639
  turn_id: input.turn_id,
567
640
  abort_signal: input.abort_signal,
568
641
  interactions: this.session_interactions,
569
- workspace_env: Object.freeze({ ...this.get_workspace_env() }),
642
+ workspace_env: Object.freeze({ ...this.read_workspace_env() }),
570
643
  });
571
644
  }
572
645
 
646
+ /**
647
+ * 读取当前目录的环境变量快照。
648
+ *
649
+ * 关键点(中文):环境属于目录(Workspace 实例),不是 Session 自身配置;没有目录时为空。
650
+ */
651
+ private read_workspace_env(): Record<string, string> {
652
+ return { ...(this.local_state.current_workspace?.get_env() ?? {}) };
653
+ }
654
+
573
655
  private create_fork_session(session_id: string): this {
574
656
  return this.create_child_session({
575
657
  agent_id: this.agent_id,
576
658
  agent_name: this.agent_name,
577
659
  agent_description: this.agent_description,
578
- workspace_path: this.workspace_path,
579
- ...(this.workspace_id ? { workspace_id: this.workspace_id } : {}),
660
+ workspace_path: this.local_state.fallback_workspace_path,
661
+ ...(this.local_state.current_workspace
662
+ ? { workspace: this.local_state.current_workspace }
663
+ : {}),
580
664
  origin: this.origin,
581
665
  store: this.create_session_store(session_id),
582
666
  create_session_store: this.create_session_store,
@@ -586,8 +670,7 @@ export class Session implements AgentSession {
586
670
  logger: this.logger,
587
671
  instruction_system_blocks: this.step_input.instruction_blocks(),
588
672
  get_instruction_system_blocks: this.get_instruction_system_blocks,
589
- get_workspace_env: this.get_workspace_env,
590
- get_workspace: this.get_workspace,
673
+ get_workspace: () => this.local_state.current_workspace,
591
674
  get_hooks: this.get_hooks,
592
675
  get_agent_model: this.get_agent_model,
593
676
  composer: this.composer,
@@ -15,6 +15,7 @@ import type {
15
15
  SessionHookContextBlock,
16
16
  SessionTurnContextHookValue,
17
17
  ToolCallContext,
18
+ WorkspaceRuntime,
18
19
  } from "@downcity/type";
19
20
  import type { BoundAgentTool, ToolCallSite } from "@/types/tool/BoundAgentTool.js";
20
21
  import type {
@@ -39,6 +40,10 @@ import { resolve_session_power_system_blocks } from "@/session/input/SessionSyst
39
40
  export class StepInput {
40
41
  private readonly options: StepInputOptions;
41
42
  private effective_instruction_blocks: AgentSessionSystemBlock[];
43
+ /** 当前目录对应的环境变量快照;由目录实例提供。 */
44
+ private readonly workspace_env_by_id = new Map<string, Record<string, string>>();
45
+ /** 当前目录变化时取消的旧订阅。 */
46
+ private workspace_env_unsubscribe?: () => void;
42
47
  /** 当前 Session 首次组装后冻结的完整 system;null 表示尚未冻结。 */
43
48
  private frozen_blocks: AgentSessionSystemBlock[] | null = null;
44
49
  /** 当前 Session 的一次性初始化任务。 */
@@ -128,8 +133,7 @@ export class StepInput {
128
133
  }
129
134
 
130
135
  /** 把当前完整 system 显式固化到 instruction.md。 */
131
- async snapshot(): Promise<void> {
132
- await this.run_mutation(async () => {
136
+ async snapshot(): Promise<void> { await this.run_mutation(async () => {
133
137
  const system_snapshot = await this.read_system();
134
138
  await this.write_snapshot(system_snapshot.blocks);
135
139
  });
@@ -163,7 +167,7 @@ export class StepInput {
163
167
  session: {
164
168
  agent_id: this.options.agent_id,
165
169
  session_id: this.options.session_id,
166
- project_root: this.options.workspace_path,
170
+ project_root: this.options.get_workspace_path(),
167
171
  created_at: new Date(this.options.get_created_at()).toISOString(),
168
172
  timezone: this.options.get_timezone(),
169
173
  },
@@ -176,12 +180,38 @@ export class StepInput {
176
180
  return this.effective_instruction_blocks.map((block) => ({ ...block }));
177
181
  }
178
182
 
183
+ /**
184
+ * 读取当前目录的环境变量。
185
+ *
186
+ * 关键点(中文)
187
+ * - 目录是可变会话配置,环境订阅也随之切换:首次遇到某个目录时建立订阅,
188
+ * 后续读取直接取其快照,不再重建。
189
+ * - 没有目录时返回空对象。
190
+ */
191
+ private read_workspace_env(): Record<string, string> {
192
+ const workspace = this.options.get_workspace?.();
193
+ if (!workspace) {
194
+ this.workspace_env_unsubscribe?.();
195
+ this.workspace_env_unsubscribe = undefined;
196
+ return {};
197
+ }
198
+ const cached = this.workspace_env_by_id.get(workspace.id);
199
+ if (cached) return cached;
200
+ // 同一 Session 只保留当前目录的订阅,避免旧目录继续推送。
201
+ this.workspace_env_unsubscribe?.();
202
+ this.workspace_env_by_id.set(workspace.id, { ...workspace.get_env() });
203
+ this.workspace_env_unsubscribe = workspace.subscribe_env(() => {
204
+ this.workspace_env_by_id.set(workspace.id, { ...workspace.get_env() });
205
+ });
206
+ return this.workspace_env_by_id.get(workspace.id) as Record<string, string>;
207
+ }
208
+
179
209
  /** 创建 Composer 共用的稳定 Session 身份快照。 */
180
210
  identity(): SessionComposeIdentity {
181
211
  return {
182
212
  agent_id: this.options.agent_id,
183
213
  session_id: this.options.session_id,
184
- project_root: this.options.workspace_path,
214
+ project_root: this.options.get_workspace_path(),
185
215
  created_at: this.options.get_created_at(),
186
216
  timezone: this.options.get_timezone(),
187
217
  };
@@ -196,7 +226,7 @@ export class StepInput {
196
226
  const instruction_system_blocks = refresh_system
197
227
  ? this.options.get_instruction_system_blocks().map((block) => ({ ...block }))
198
228
  : this.instruction_blocks();
199
- const workspace_env = Object.freeze({ ...this.options.get_workspace_env() });
229
+ const workspace_env = Object.freeze({ ...this.read_workspace_env() });
200
230
  // Power system 参与 Compose,必须先看到当前检查点已确定的 env 与 instruction。
201
231
  turn_context?.step.commit({
202
232
  workspace_env,
@@ -401,7 +431,7 @@ export class StepInput {
401
431
  private create_session_call_context(
402
432
  workspace_env: Readonly<Record<string, string>>,
403
433
  ): ToolCallContext {
404
- const workspace = this.options.get_workspace?.();
434
+ const workspace = this.resolve_active_workspace();
405
435
  return Object.freeze({
406
436
  agent_id: this.options.agent_id,
407
437
  agent_name: this.options.agent_name,
@@ -424,7 +454,7 @@ export class StepInput {
424
454
  ): ToolCallContext {
425
455
  const workspace_env = turn_context.step.workspace_env;
426
456
  const agent_systems = turn_context.step.agent_systems;
427
- const workspace = this.options.get_workspace?.();
457
+ const workspace = this.resolve_active_workspace();
428
458
  return Object.freeze({
429
459
  agent_id: this.options.agent_id,
430
460
  agent_name: this.options.agent_name,
@@ -441,6 +471,11 @@ export class StepInput {
441
471
  ...(workspace_env ? { workspace_env } : {}),
442
472
  });
443
473
  }
474
+
475
+ /** 解析当前生效的 Workspace 实例。 */
476
+ private resolve_active_workspace(): WorkspaceRuntime | undefined {
477
+ return this.options.get_workspace?.();
478
+ }
444
479
  }
445
480
 
446
481
  /** 把 Turn pipeline 输出限制为低权限动态参考内容块。 */