-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathOfficeBridge.cs
More file actions
459 lines (425 loc) · 20.4 KB
/
Copy pathOfficeBridge.cs
File metadata and controls
459 lines (425 loc) · 20.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
451
452
453
454
455
456
457
458
459
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using AIOrchestrator;
// ═══════════════════════════════════════════════════════════════════════
// OfficeBridge — duplex WebSocket hub between AgentBridge and OfficeManager
//
// OfficeManager (the 16-bit office app, served at /OfficeManager) renders an
// employee for EVERY live agent instance: a session (whatever medium created
// it — TUI chat, /v1/chat/completions, SIP, Telegram, OfficeManager itself),
// a stateless one-shot API call, or a subagent of any depth. The mapping is
// keyed on AgentHarness.GlobalProgress (process-wide: every orchestrator in
// THIS process, however it was created) plus the SessionStore lifecycle
// events (sessions exist even before their first ExecuteAction). Agents
// created by OTHER processes (AIOffice desktop app, voice panels, schedulers)
// are forwarded here via AgentHarness.ForwardGlobalProgressTo → POST
// /v1/office/events (see IngestExternalEvent) and get the same treatment.
//
// Wire protocol (JSON text frames, camelCase):
// Server → Client:
// {"type":"snapshot","employees":[{empId,agentId,kind,sprite,label,running}...]}
// {"type":"spawn", empId, agentId, kind:"idle"|"session"|"stateless"|"subagent", sprite, label}
// {"type":"assign", empId, agentId, label} // an idle employee became a session agent
// {"type":"running",empId, value:bool} // agent run started/finished
// {"type":"method", empId, method} // tool method the agent is executing
// {"type":"closed", empId} // agent instance closed → return to door, despawn
// {"type":"chat", empId, role:"user"|"assistant"|"sys", text}
// {"type":"error", text}
// Client → Server:
// {"type":"hello"}
// {"type":"chat_send", empId, prompt}
// {"type":"close", empId}
//
// chat_send on an idle employee creates a session (AgentHarness) for it and
// spawns a replacement idle employee at the door; on a session employee it
// runs ExecuteAction on that session (the same conversation the TUI would
// use). Subagent/stateless employees are visual-only and refuse chat.
// ═══════════════════════════════════════════════════════════════════════
/// <summary>WebSocket hub + agent-lifecycle tracker for the OfficeManager visual protocol.</summary>
public static class OfficeBridge
{
private const int IdleCount = 1; // one always-present roamable employee (hire pool)
private const int SpriteCount = 5; // employee A..E sprite sheets
private static string _provider = "DeepSeekBridge";
private static bool _anonymize;
/// <summary>One office employee. Server-authoritative; the browser mirrors it via events.</summary>
private sealed class Employee
{
public string EmpId = "";
public string? AgentId; // agentKey: instanceId / instanceId::subagentId
public string Kind = "idle"; // idle | session | stateless | subagent
public int Sprite;
public string Label = "";
public bool Running;
}
private static readonly ConcurrentDictionary<string, Employee> Employees = new(); // empId → employee
private static readonly ConcurrentDictionary<string, string> ByAgent = new(); // agentKey → empId
private static readonly HashSet<string> SessionAgents = new(); // instanceIds backed by a session
private static readonly HashSet<string> PendingAssign = new(); // idle empIds waiting for the next session
private static readonly ConcurrentDictionary<string, WebSocketClient> Clients = new();
private static readonly object Sync = new();
private static int _nextEmp = 1;
private static int _nextAgentNo = 1;
private static int _nextSprite;
private sealed class WebSocketClient
{
public WebSocket Socket = null!;
public Channel<string> Out = Channel.CreateUnbounded<string>();
public Task Sender = Task.CompletedTask;
}
/// <summary>Called once at startup (Program.cs) with the same provider/anonymize the
/// session store uses, then the hub tracks every agent instance in this process.</summary>
public static void Init(string provider, bool anonymize)
{
_provider = provider;
_anonymize = anonymize;
SessionStore.SessionCreated += OnSessionCreated;
SessionStore.SessionRemoved += OnSessionRemoved;
AgentHarness.GlobalProgress += OnGlobalProgress;
lock (Sync)
{
for (int i = 0; i < IdleCount; i++) SpawnLocked("idle", null, "");
}
}
// ────────────────────────────────────────────────────────────────────
// Agent lifecycle → employee events
// ────────────────────────────────────────────────────────────────────
private static void OnSessionCreated(ActiveSession session)
{
lock (Sync)
{
SessionAgents.Add(session.Id);
// A session created because the user started a chat with an idle employee takes over
// that employee (assign) instead of spawning a duplicate at the door.
if (PendingAssign.Count > 0)
{
var empId = PendingAssign.First();
PendingAssign.Remove(empId);
var emp = Employees[empId];
emp.AgentId = session.Id;
emp.Kind = "session";
emp.Label = ShortLabel(session.Id);
ByAgent[session.Id] = empId;
Broadcast(new { type = "assign", empId, agentId = session.Id, label = emp.Label });
SpawnLocked("idle", null, ""); // replacement idle appears at the door
}
else
{
SpawnLocked("session", session.Id, ShortLabel(session.Id));
}
}
}
private static void OnSessionRemoved(ActiveSession session)
{
lock (Sync)
{
SessionAgents.Remove(session.Id);
if (ByAgent.TryRemove(session.Id, out var empId))
{
Employees.TryRemove(empId, out _);
Broadcast(new { type = "closed", empId });
}
}
}
private static void OnGlobalProgress(object? sender, AgentHarness.AgentProgressEventArgs e)
{
lock (Sync)
{
var key = e.SubagentId == null ? e.InstanceId ?? "" : $"{e.InstanceId}::{e.SubagentId}";
if (key.Length == 0) return;
ByAgent.TryGetValue(key, out var empId);
var known = empId != null;
if (e.SubagentId != null)
{
// Subagent events: spawn on first Running, methods while running, closed on completion.
if (!known && e.State == AgentHarness.AgentState.Running)
empId = SpawnLocked("subagent", key, e.SubagentId);
if (empId == null) return;
switch (e.State)
{
case AgentHarness.AgentState.Running: SetRunningLocked(empId, true); break;
case AgentHarness.AgentState.Iteration:
SetRunningLocked(empId, true);
if (!string.IsNullOrEmpty(e.MethodName))
Broadcast(new { type = "method", empId, method = e.MethodName });
break;
case AgentHarness.AgentState.Completed:
case AgentHarness.AgentState.Failed:
CloseLocked(empId);
break;
}
}
else
{
// Main-agent events: a stateless instance appears at its first Running and closes
// when the run ends (its instance dies with the request); session instances stay
// until SessionRemoved (conversation alive between runs).
if (!known && e.State == AgentHarness.AgentState.Running)
{
var kind = SessionAgents.Contains(e.InstanceId ?? "") ? "session" : "stateless";
empId = SpawnLocked(kind, key, kind == "session" ? ShortLabel(key) : $"agent {_nextAgentNo++}");
}
if (empId == null) return;
switch (e.State)
{
case AgentHarness.AgentState.Running: SetRunningLocked(empId, true); break;
case AgentHarness.AgentState.Iteration:
SetRunningLocked(empId, true);
if (!string.IsNullOrEmpty(e.MethodName))
Broadcast(new { type = "method", empId, method = e.MethodName });
break;
case AgentHarness.AgentState.Initiative:
SetRunningLocked(empId, false);
if (!string.IsNullOrEmpty(e.Message))
Broadcast(new { type = "chat", empId, role = "assistant", text = e.Message });
break;
case AgentHarness.AgentState.Completed:
case AgentHarness.AgentState.Failed:
SetRunningLocked(empId, false);
if (!SessionAgents.Contains(e.InstanceId ?? ""))
CloseLocked(empId); // one-shot instance: done → home
break;
}
}
}
}
/// <summary>Creates an employee, registers it and broadcasts the spawn event. Call under Sync.</summary>
private static string SpawnLocked(string kind, string? agentKey, string label)
{
var empId = $"emp-{_nextEmp++}";
var emp = new Employee
{
EmpId = empId,
AgentId = agentKey,
Kind = kind,
Sprite = _nextSprite++ % SpriteCount,
Label = label,
};
Employees[empId] = emp;
if (agentKey != null) ByAgent[agentKey] = empId;
Broadcast(new { type = "spawn", empId, agentId = agentKey, kind, sprite = emp.Sprite, label });
return empId;
}
private static void SetRunningLocked(string empId, bool value)
{
if (!Employees.TryGetValue(empId, out var emp)) return;
emp.Running = value;
Broadcast(new { type = "running", empId, value });
}
private static void CloseLocked(string empId)
{
if (!Employees.TryRemove(empId, out var emp)) return;
if (emp.AgentId != null) ByAgent.TryRemove(emp.AgentId, out _);
Broadcast(new { type = "closed", empId });
}
// ────────────────────────────────────────────────────────────────────
// Client protocol
// ────────────────────────────────────────────────────────────────────
/// <summary>Accepts one WebSocket client (the browser tab) and serves it until it disconnects.</summary>
public static async Task HandleClientAsync(WebSocket ws, CancellationToken ct)
{
var key = Guid.NewGuid().ToString("N");
var client = new WebSocketClient { Socket = ws };
Clients[key] = client;
client.Sender = Task.Run(() => SendLoopAsync(client, ct));
try
{
SendSnapshot(client);
var buffer = new byte[16384];
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
var result = await ws.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close) break;
if (result.MessageType != WebSocketMessageType.Text) continue;
var text = Encoding.UTF8.GetString(buffer, 0, result.Count);
HandleClientMessage(text);
}
}
catch { /* disconnect or aborted — cleanup below */ }
finally
{
Clients.TryRemove(key, out _);
client.Out.Writer.TryComplete();
try { await client.Sender; } catch { }
try { if (ws.State != WebSocketState.Closed) await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); } catch { }
}
}
private static void HandleClientMessage(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var type = root.TryGetProperty("type", out var t) ? t.GetString() : null;
switch (type)
{
case "hello":
foreach (var c in Clients.Values) SendSnapshot(c);
break;
case "chat_send":
{
var empId = Str(root, "empId");
var prompt = Str(root, "prompt");
if (empId != null && !string.IsNullOrWhiteSpace(prompt))
_ = Task.Run(() => HandleChatSendAsync(empId, prompt.Trim()));
break;
}
case "close":
{
var empId = Str(root, "empId");
if (empId != null) HandleClose(empId);
break;
}
}
}
catch { /* malformed frame — ignore */ }
}
private static string? Str(JsonElement root, string name) =>
root.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
private static async Task HandleChatSendAsync(string empId, string prompt)
{
ActiveSession? session = null;
lock (Sync)
{
if (!Employees.TryGetValue(empId, out var emp)) { Error("Unknown employee."); return; }
switch (emp.Kind)
{
case "subagent":
Chat(empId, "sys", "This employee is a subagent — its conversation is managed by the main agent.");
return;
case "stateless":
Chat(empId, "sys", "This agent is a one-shot API call — it cannot be chatted with.");
return;
case "idle":
// Start a conversation with the idle employee: it becomes a real agent (same
// rules as any session) and a replacement idle employee appears at the door.
PendingAssign.Add(empId);
try
{
session = SessionStore.Create(_provider, _anonymize);
}
catch (Exception ex)
{
PendingAssign.Remove(empId);
Error($"Could not start an agent: {ex.Message}");
return;
}
break;
default:
session = SessionStore.Get(emp.AgentId ?? "");
if (session == null)
{
Chat(empId, "sys", "This conversation has expired — start a new one.");
return;
}
break;
}
}
Chat(empId, "user", prompt);
await RunAgentAsync(empId, session, prompt);
}
private static async Task RunAgentAsync(string empId, ActiveSession session, string prompt)
{
SetRunning(empId, true);
try
{
// One chat at a time per conversation (same gate the HTTP endpoints use); the agent
// set matches a chat without a model (default-agent preset + core tools).
await session.Gate.WaitAsync();
AgentResult result;
try
{
result = await Task.Run(() => session.Orchestrator.ExecuteAction(
prompt, AgentTools.Resolve(null), maxIterations: 200, isLocalUser: true));
}
finally { session.Gate.Release(); }
var text = result.Message
?? (result.Success ? AgentBridge.Resources.Dictionary.NoOutputGenerated : result.Error ?? "error");
Chat(empId, "assistant", text);
}
catch (Exception ex)
{
Chat(empId, "sys", $"Agent failed: {ex.Message}");
}
finally
{
SetRunning(empId, false);
}
}
private static void HandleClose(string empId)
{
string? agentId = null;
lock (Sync)
{
if (Employees.TryGetValue(empId, out var emp) && emp.Kind == "session")
agentId = emp.AgentId;
}
// SessionStore.TryRemove fires SessionRemoved → the closed event reaches the browser.
if (agentId != null) SessionStore.TryRemove(agentId);
}
/// <summary>Accepts events forwarded by AgentHarness.ForwardGlobalProgressTo from other
/// processes (AIOffice app, voice panels, ...) — the same stream as OnGlobalProgress.</summary>
public static void IngestExternalEvent(JsonElement root)
{
var state = Str(root, "state");
if (!Enum.TryParse<AgentHarness.AgentState>(state, out var parsed)) return;
OnGlobalProgress(null, new AgentHarness.AgentProgressEventArgs
{
State = parsed,
Iteration = root.TryGetProperty("iteration", out var it) && it.ValueKind == JsonValueKind.Number ? it.GetInt32() : 0,
MethodName = Str(root, "methodName"),
Message = Str(root, "message"),
Error = Str(root, "error"),
SubagentId = Str(root, "subagentId"),
Level = root.TryGetProperty("level", out var lv) && lv.ValueKind == JsonValueKind.Number ? lv.GetInt32() : 0,
InstanceId = Str(root, "instanceId"),
});
}
// ────────────────────────────────────────────────────────────────────
// Broadcast helpers
// ────────────────────────────────────────────────────────────────────
private static void Broadcast(object payload)
{
var json = JsonSerializer.Serialize(payload);
foreach (var c in Clients.Values) c.Out.Writer.TryWrite(json);
}
private static void SendSnapshot(WebSocketClient client)
{
Employee[] employees;
lock (Sync) employees = Employees.Values.ToArray();
client.Out.Writer.TryWrite(JsonSerializer.Serialize(new
{
type = "snapshot",
employees = employees.Select(e => new
{
empId = e.EmpId, agentId = e.AgentId, kind = e.Kind,
sprite = e.Sprite, label = e.Label, running = e.Running
}),
}));
}
private static void SetRunning(string empId, bool value)
{
lock (Sync) SetRunningLocked(empId, value);
}
private static void Chat(string empId, string role, string text) =>
Broadcast(new { type = "chat", empId, role, text });
private static void Error(string text) => Broadcast(new { type = "error", text });
private static string ShortLabel(string id) =>
id.Length > 12 ? id[..12] : id;
private static async Task SendLoopAsync(WebSocketClient client, CancellationToken ct)
{
try
{
await foreach (var json in client.Out.Reader.ReadAllAsync(ct))
{
if (client.Socket.State != WebSocketState.Open) break;
var bytes = Encoding.UTF8.GetBytes(json);
await client.Socket.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
}
}
catch { /* client gone — receive loop cleans up */ }
}
}