-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathsession_client.hpp
More file actions
450 lines (385 loc) · 18.4 KB
/
Copy pathsession_client.hpp
File metadata and controls
450 lines (385 loc) · 18.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
#pragma once
// SessionClient: 会话客户端抽象层(openspec add-web-daemon Section 7)。
//
// 一个 SessionClient 给"上层调用者"(daemon HTTP handler / 浏览器侧 RemoteClient
// / 未来 IDE 插件等)提供统一接口去管理 AgentLoop 实例:create/list/destroy +
// 订阅事件流 + 发输入 + 回应权限请求 + abort。
//
// v1 只有一个实现 LocalSessionClient(同进程,直接持有 SessionRegistry)。
// 浏览器侧的 RemoteSessionClient 由前端 change(add-web-chat-ui)落地。
//
// 设计原则:
// - 接口 hpp 不依赖任何具体实现头(只 string + json + functional + 标准库)
// - SessionEvent 是事件流的最小公共表示;AgentLoop 内部产物经 adapter 转过来
// - SessionClient 方法可阻塞(create/list);事件订阅是 push 模式(回调)
#include <cstdint>
#include <chrono>
#include <functional>
#include <optional>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "../provider/llm_provider.hpp"
namespace acecode {
// ----- 事件流 -----
// SessionEvent::Kind 与 spec design.md WS 协议的 type 一一对应。
// 服务端 → 客户端方向。
enum class SessionEventKind {
Token, // payload: {"text": "..."}
Reasoning, // payload: {"text": "..."} (DeepSeek thinking 等)
AgentProgress, // payload: {"phase":"...", "label":"...", ...} (Web activity indicator)
ModelStepStart, // payload: {"step_index":N}
ModelStepFinish, // payload: {"step_index":N,"reason":"...","usage":{...}}
Message, // payload: 一条完整 ChatMessage(JSON 序列化形式)
ToolStart, // payload: {"tool":"...", "command_preview":"..."}
ToolUpdate, // payload: {"tool":"...", "tail":[...], "partial":"...", "total_lines":N, "total_bytes":N}
ToolEnd, // payload: {"tool":"...", "result_summary": {...}, "ok": bool}
TurnDiff, // payload: persisted turn_net_diff record
PermissionRequest, // payload: {"request_id":"...", "tool":"...", "args": {...}}
PermissionClosed, // payload: {"request_id":"...","choice":"...","reason":"..."}
QuestionRequest, // payload: {"request_id":"...", "questions":[...]} (AskUserQuestion 工具)
QuestionClosed, // payload: {"request_id":"...", "reason":"..."} (AskUserQuestion lifecycle)
Usage, // payload: {"input": N, "output": N, ...}
// Full visible transcript replacement for retry/recovery cleanup. Normal
// compact success appends marker messages and a hidden checkpoint instead.
TranscriptReplace, // payload: {"messages": [...]}
GoalUpdated, // payload: {"session_id":"...", "goal": {...}}
GoalCleared, // payload: {"session_id":"..."}
TodoUpdated, // payload: {"session_id":"...", "todos": [...], "summary": {...}}
SessionUpdated, // payload: {"session_id":"...", "title":"...", ...}
BusyChanged, // payload: {"busy": bool,"outcome"?:completed|error|aborted}
Done, // payload: {"outcome"?:completed|error|aborted}
Error, // payload: {"reason":"...", "request_id":"..."(可选)}
};
struct SessionEvent {
SessionEventKind kind;
std::uint64_t seq = 0; // 该 session 内单调递增,从 1 开始
std::int64_t timestamp_ms = 0;
nlohmann::json payload;
};
// ----- 客户端 → 服务端的命令 -----
enum class PermissionDecisionChoice {
Allow,
Deny,
AllowSession, // = AlwaysAllow,这次允许 + 本 session 内不再问
};
struct PermissionDecision {
std::string request_id;
PermissionDecisionChoice choice = PermissionDecisionChoice::Deny;
};
enum class BuiltinCommandStatus {
Accepted,
UnknownSession,
UnsupportedCommand,
Failed,
};
struct BuiltinCommandRequest {
std::string name;
std::string args;
std::string display_text;
};
struct BuiltinCommandResult {
BuiltinCommandStatus status = BuiltinCommandStatus::Failed;
std::string message;
};
enum class TurnSteerStatus {
Accepted,
InvalidInput,
UnknownSession,
NoActiveTurn,
NonSteerable,
TurnMismatch,
QueueFull,
};
struct TurnSteerResult {
TurnSteerStatus status = TurnSteerStatus::NoActiveTurn;
std::string turn_id;
std::string message;
bool accepted() const { return status == TurnSteerStatus::Accepted; }
};
enum class SideQuestionStatus {
Ok,
InvalidQuestion,
UnknownSession,
ContextNotReady,
ProviderUnavailable,
Failed,
};
struct SideQuestionResult {
SideQuestionStatus status = SideQuestionStatus::Failed;
std::string question;
std::string answer;
std::string error;
};
constexpr std::size_t kMaxSideQuestionBytes = 16000;
// ----- Session 创建参数 -----
struct SessionOptions {
// Workspace execution context. Empty cwd = use the daemon compatibility
// workspace. workspace_hash is optional when cwd is provided and will be
// derived from cwd by the registry.
std::string cwd;
std::string workspace_hash;
bool no_workspace = false;
// 可选 model override(对应 saved_models.name)。
// 留空 = 用 daemon 启动时的 default。
std::string model_name;
// 可选 permission mode override(default / accept-edits / plan / yolo)。
// 留空 = 用 daemon/TUI 共享默认值。
std::string permission_mode;
// 可选初始系统消息或 prompt 注入。v1 留空。
std::string initial_user_message;
// Optional initial expert binding for this session. expert_member_id is
// reserved for validated team-member child sessions created internally by
// spawn_subagent; ordinary clients select only expert_id and may later
// switch it through the active-session API.
std::string expert_id;
std::string expert_member_id;
// 是否在 session 创建后立刻启动 agent loop 处理 initial_user_message。
// 留 false 让客户端控制时机。
bool auto_start = false;
// 子代理派生深度:0 = 普通会话,1 = spawn_subagent 创建的子会话。
// 子代理不允许再派生(spawn_subagent 工具按此拒绝),防递归失控。
int subagent_depth = 0;
// spawn_subagent 派生时的父会话 id。非空 → 写入 meta 持久化,子会话
// 从常规列表隐藏、归入父会话的「后台任务」面板;daemon 重启后依然生效。
std::string parent_session_id;
// 可选调用方自定的新会话 id(headless -p --session-id:脚本免解析
// stdout 即可确定性地 --resume)。留空 = registry 自动生成。调用方
// 负责保证 id 文件名安全且未被占用;registry 不做碰撞检查。
std::string preset_session_id;
// Daemon-owned LOOP execution metadata. Ordinary interactive sessions
// leave these empty/false. The system context is provider-facing only and
// is never persisted as a visible user message.
bool loop_execution = false;
std::string loop_id;
std::string loop_run_id;
std::string loop_system_context;
};
// ----- Current session model state -----
struct SessionModelState {
// Selected saved_models entry name, or an ad-hoc "(session:...)"
// name for legacy metadata without a saved-model reference.
std::string name;
std::string provider;
std::string model;
int context_window = 0;
// True when name points to a saved model that no longer exists in global
// configuration. provider/model stay empty so callers do not treat it as
// a usable provider.
bool deleted = false;
};
// ----- Session 元数据(给 list_sessions 用) -----
struct SessionInfo {
std::string id;
std::string cwd;
std::string workspace_hash;
std::string created_at; // ISO 8601
std::string updated_at;
std::string summary; // 最后一条 user 消息的截断
std::string model_name; // saved_models name / ad-hoc
std::string provider;
std::string model;
int context_window = 0;
bool model_deleted = false;
std::string title;
std::string title_source;
int message_count = 0;
int turn_count = 0;
std::string permission_mode = "default";
TokenUsage last_token_usage;
TokenUsage session_token_usage;
bool active = false; // 是否在 SessionRegistry 内存活
bool busy = false; // 是否正在处理当前轮
bool no_workspace = false;
std::string parent_session_id; // 非空 = spawn_subagent 子会话(后台任务)
std::string active_turn_id; // 非空 = 当前可接受 steering 的 regular turn
std::string expert_id;
std::string expert_member_id;
std::string expert_display_name;
std::string expert_type;
std::string expert_source;
bool expert_missing = false;
// Active managed worktree snapshot. `cwd` intentionally remains the
// workspace/session-storage root; this field is the current file/tool root.
std::string worktree_path;
std::string worktree_name;
std::string worktree_branch;
};
// ----- AskUserQuestion 回应(client→server) -----
// 一个仍在等待整批回答的 AskUserQuestion 请求快照。created_at/deadline
// 使用同一进程内的 steady clock,避免系统时钟调整导致 channel 侧重算超时。
// order 是 prompter 内的创建顺序,用于订阅实时事件后再合并快照时保持 FIFO。
struct PendingQuestionRequestSnapshot {
using Clock = std::chrono::steady_clock;
std::string request_id;
nlohmann::json questions = nlohmann::json::array();
std::uint64_t order = 0;
Clock::time_point created_at{};
std::optional<Clock::time_point> deadline;
};
// 注: 完整的结构体定义在 ask_user_question_prompter.hpp。这里 fwd 声明,
// 让 SessionClient 接口不强依赖 prompter 头(它属于 daemon 实现细节)。
struct AskUserQuestionResponse;
// respond_question 的原子 first-wins 结果。Closed 同时覆盖未知、已回答、
// 已超时和已结束 request id;UnknownSession 单独暴露路由失效。
enum class QuestionResponseStatus {
Accepted,
Closed,
UnknownSession,
};
// ----- 主接口 -----
class SessionClient {
public:
using EventListener = std::function<void(const SessionEvent&)>;
using SubscriptionId = std::uint64_t;
virtual ~SessionClient() = default;
// 创建一个新 session,返回 session_id。同步阻塞直到 SessionRegistry 完成
// 注册 + AgentLoop 起线程。
virtual std::string create_session(const SessionOptions& opts) = 0;
// 从当前 cwd 的磁盘历史恢复一个 session 到内存 registry。若该 id 已经
// active,直接返回 true,不在同一 daemon 内创建第二份同 id 上下文。
virtual bool resume_session(const std::string& id, const SessionOptions& opts = {}) = 0;
// 列出当前 daemon 内的 session(内存活跃 + 磁盘历史合并去重)。
virtual std::vector<SessionInfo> list_sessions() = 0;
// 销毁 session: abort 当前轮 + join worker + 从 registry 移除。
// 不会删除磁盘上的 jsonl/meta 文件(那是 cleanup_old_sessions 的事)。
virtual void destroy_session(const std::string& id) = 0;
// 订阅事件流。`since_seq` > 0 时,先回放缓存里 seq > since_seq 的旧事件
// (供断线重连补齐),再继续推实时新事件。返回 SubscriptionId 用于退订。
virtual SubscriptionId subscribe(const std::string& session_id,
EventListener on_event,
std::uint64_t since_seq = 0) = 0;
// 退订(线程安全)。
virtual void unsubscribe(const std::string& session_id, SubscriptionId sub) = 0;
// 发送一条用户输入。非阻塞,内部入队到 AgentLoop worker。
// 返回 false 表示 session 不在当前 registry 中。
virtual bool send_input(const std::string& session_id, const std::string& text) = 0;
// 与上面相同,但允许 daemon 在 LLM-prompt 与 UI-display 之间分离 —
// `text` 进 LLM history(可能是 daemon 端 expander 展开过的字符串),
// `display_text` 进 ChatMessage.metadata.display_text 给 UI 渲染原文。
// 默认实现直接退化到单参版本(忽略 display_text);LocalSessionClient 会重写。
virtual bool send_input(const std::string& session_id,
const std::string& text,
const std::string& display_text) {
(void)display_text;
return send_input(session_id, text);
}
// Structured variant used by multimodal clients. Implementations that do
// not understand attachments degrade to the visible text.
virtual bool send_input(const std::string& session_id, const UserInput& input) {
return send_input(session_id, input.text, input.display_text);
}
// Append input to the currently running regular turn. Implementations must
// validate expected_turn_id atomically with enqueueing so callers cannot
// accidentally steer a replacement turn.
virtual TurnSteerResult steer_input(const std::string& session_id,
const std::string& expected_turn_id,
const UserInput& input) {
(void)session_id;
(void)expected_turn_id;
(void)input;
return {
TurnSteerStatus::NonSteerable,
{},
"active-turn steering is unavailable",
};
}
// Abort the matching active turn after atomically promising the input as a
// new high-priority regular turn. The default keeps remote/legacy clients
// source-compatible while reporting that the operation is unavailable.
virtual TurnSteerResult interrupt_turn(
const std::string& session_id,
const std::string& expected_turn_id,
const UserInput& input) {
(void)session_id;
(void)expected_turn_id;
(void)input;
return {
TurnSteerStatus::NonSteerable,
{},
"interrupting turn steering is unavailable",
};
}
// Execute a daemon-owned builtin command, currently limited to `/init` and
// `/compact`. This is intentionally separate from send_input so command
// text is not skill-expanded or sent to the model as an ordinary message.
virtual BuiltinCommandResult execute_builtin_command(
const std::string& session_id,
const BuiltinCommandRequest& request) = 0;
// 回应一个之前推送的 permission_request。线程安全。
// 未知 request_id / 已超时的请求 = no-op。
virtual void respond_permission(const std::string& session_id,
const PermissionDecision& decision) = 0;
// 回应一个之前推送的 question_request(AskUserQuestion 工具)。线程安全。
// first-wins:只有真正写入 pending response 的调用返回 Accepted。
virtual QuestionResponseStatus respond_question(
const std::string& session_id,
const std::string& request_id,
const AskUserQuestionResponse& response) = 0;
// 获取 session 当前未决 QuestionRequest 的创建顺序快照。空 vector 表示
// session 支持该边界但当前没有请求;nullopt 表示未知 session 或实现不支持。
virtual std::optional<std::vector<PendingQuestionRequestSnapshot>>
snapshot_pending_questions(const std::string& session_id) {
(void)session_id;
return std::nullopt;
}
// 请求中止当前轮(不销毁 session)。
virtual void abort(const std::string& session_id) = 0;
};
// ----- helpers (header-only) -----
inline const char* to_string(SessionEventKind k) {
switch (k) {
case SessionEventKind::Token: return "token";
case SessionEventKind::Reasoning: return "reasoning";
case SessionEventKind::AgentProgress: return "agent_progress";
case SessionEventKind::ModelStepStart: return "model_step_start";
case SessionEventKind::ModelStepFinish: return "model_step_finish";
case SessionEventKind::Message: return "message";
case SessionEventKind::ToolStart: return "tool_start";
case SessionEventKind::ToolUpdate: return "tool_update";
case SessionEventKind::ToolEnd: return "tool_end";
case SessionEventKind::TurnDiff: return "turn_diff";
case SessionEventKind::PermissionRequest: return "permission_request";
case SessionEventKind::PermissionClosed: return "permission_closed";
case SessionEventKind::QuestionRequest: return "question_request";
case SessionEventKind::QuestionClosed: return "question_closed";
case SessionEventKind::Usage: return "usage";
case SessionEventKind::TranscriptReplace: return "transcript_replace";
case SessionEventKind::GoalUpdated: return "goal_updated";
case SessionEventKind::GoalCleared: return "goal_cleared";
case SessionEventKind::TodoUpdated: return "todo_updated";
case SessionEventKind::SessionUpdated: return "session_updated";
case SessionEventKind::BusyChanged: return "busy_changed";
case SessionEventKind::Done: return "done";
case SessionEventKind::Error: return "error";
}
return "unknown";
}
inline const char* to_string(PermissionDecisionChoice c) {
switch (c) {
case PermissionDecisionChoice::Allow: return "allow";
case PermissionDecisionChoice::Deny: return "deny";
case PermissionDecisionChoice::AllowSession: return "allow_session";
}
return "deny";
}
inline const char* to_string(TurnSteerStatus status) {
switch (status) {
case TurnSteerStatus::Accepted: return "accepted";
case TurnSteerStatus::InvalidInput: return "invalid_input";
case TurnSteerStatus::UnknownSession: return "unknown_session";
case TurnSteerStatus::NoActiveTurn: return "no_active_turn";
case TurnSteerStatus::NonSteerable: return "non_steerable";
case TurnSteerStatus::TurnMismatch: return "turn_mismatch";
case TurnSteerStatus::QueueFull: return "queue_full";
}
return "no_active_turn";
}
inline std::optional<PermissionDecisionChoice>
parse_permission_choice(const std::string& s) {
if (s == "allow") return PermissionDecisionChoice::Allow;
if (s == "deny") return PermissionDecisionChoice::Deny;
if (s == "allow_session") return PermissionDecisionChoice::AllowSession;
return std::nullopt;
}
} // namespace acecode