Repository navigation
Expand file tree
/
Copy pathsession.ts
More file actions
174 lines (156 loc) · 6.11 KB
/
Copy pathsession.ts
File metadata and controls
174 lines (156 loc) · 6.11 KB
Open symbols panel
<
8000
button data-component="IconButton" type="button" title="More file actions" data-testid="more-file-actions-button" aria-haspopup="true" aria-expanded="false" tabindex="0" class="prc-Button-ButtonBase-9n-Xk js-blob-dropdown-click IconButton__0qsoRg prc-Button-IconButton-fyge7" data-loading="false" data-no-visuals="true" data-size="small" data-variant="invisible" aria-labelledby="_R_fkecpala9lik5_" id="_R_kecpala9lik5_">Edit and raw actions
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
import { buildAssistantSystemPrompt } from "../prompts";
import { assistantBridge } from "./bridge-client";
import { DirectAssistantBackend } from "./direct-backend";
import type { LlmMessage, LlmToolDefinition } from "./llm-protocol";
import type { ResolvedAssistantModel } from "./models";
import { toolRegistry, type ToolRegistry } from "./tool-registry";
import type {
AssistantBridgeConfig,
AssistantContext,
AssistantTurnCallbacks,
AssistantTurnHandle,
} from "./types";
/**
* One assistant conversation. Owns the message history and turn state and
* drives either the on-phone provider loop (direct) or the external agent
* bridge. The UI talks only to this surface, so AssistantLayer is
* backend-agnostic.
*
* Sessions are cheap and idle-expire (see isExpired): the shell starts a fresh
* one for a new wakeword and reuses the current one for a follow-up. In
* external mode the conversation history lives on the agent's machine (one
* long-lived session there), so expiry here only affects the overlay UI.
*/
/** Which engine answers utterances, plus what it needs to do so. */
export type AssistantBackendConfig =
| { kind: "direct"; llm: ResolvedAssistantModel }
| { kind: "external"; bridge: AssistantBridgeConfig };
/** A new PTT after this much idle starts a fresh conversation. */
const SESSION_IDLE_MS = 10 * 60 * 1000;
/** Trim history from the head once it grows past this many messages. */
const MAX_HISTORY_MESSAGES = 40;
export class AssistantSession {
private readonly directBackend = new DirectAssistantBackend();
private readonly messages: LlmMessage[] = [];
private turnHandle: AssistantTurnHandle | null = null;
private lastActivityMs = Date.now();
// API-safe tool name -> canonical registry name. Provider APIs restrict tool
// names, so dotted registry names are sanitized and mapped back on calls.
private readonly toolNameMap = new Map<string, string>();
constructor(
private readonly config: AssistantBackendConfig,
private readonly registry: ToolRegistry = toolRegistry,
) {}
matchesConfiguration(config: AssistantBackendConfig): boolean {
if (this.config.kind !== config.kind) return false;
if (this.config.kind === "direct" && config.kind === "direct") {
return (
this.config.llm.provider === config.llm.provider &&
this.config.llm.model === config.llm.model &&
this.config.llm.apiKey === config.llm.apiKey
);
}
if (this.config.kind === "external" && config.kind === "external") {
return (
this.config.bridge.host === config.bridge.host &&
this.config.bridge.port === config.bridge.port &&
this.config.bridge.token === config.bridge.token
);
}
return false;
}
/** Whether this session has been idle long enough to retire. */
isExpired(nowMs = Date.now()): boolean {
return nowMs - this.lastActivityMs > SESSION_IDLE_MS;
}
isTurnActive(): boolean {
return this.turnHandle !== null;
}
/** Begin a turn from a spoken utterance. Only one turn runs at a time. */
sendUtterance(text: string, ctx: AssistantContext, callbacks: AssistantTurnCallbacks): void {
if (this.turnHandle) {
callbacks.onError("The assistant is still working on the previous request");
return;
}
this.lastActivityMs = Date.now();
const finish = () => {
this.turnHandle = null;
this.lastActivityMs = Date.now();
};
const wrappedCallbacks: AssistantTurnCallbacks = {
onTextDelta: callbacks.onTextDelta,
onToolActivity: callbacks.onToolActivity,
onTurnDone: (result) => {
finish();
callbacks.onTurnDone(result);
},
onError: (message) => {
finish();
callbacks.onError(message);
},
};
if (this.config.kind === "external") {
// History and the agent loop live on the agent's machine; the phone just
// streams this turn. The overlay keeps its own display state.
this.turnHandle = assistantBridge.sendUtterance(text, ctx, wrappedCallbacks);
return;
}
this.messages.push({ role: "user", content: text });
this.trimHistory();
const llm = this.config.llm;
this.turnHandle = this.directBackend.runTurn({
provider: llm.provider,
apiKey: llm.apiKey,
model: llm.model,
effort: llm.effort,
system: buildAssistantSystemPrompt(ctx),
messages: this.messages,
buildTools: () => this.buildToolDefinitions(),
registry: this.registry,
resolveToolName: (apiName) => this.toolNameMap.get(apiName) ?? apiName,
callbacks: wrappedCallbacks,
});
}
cancel(): void {
this.turnHandle?.cancel();
this.turnHandle = null;
this.lastActivityMs = Date.now();
}
private buildToolDefinitions(): LlmToolDefinition[] {
this.toolNameMap.clear();
return this.registry.listTools().map((spec) => {
const apiName = this.toApiToolName(spec.name);
this.toolNameMap.set(apiName, spec.name);
return { name: apiName, description: spec.description, input_schema: spec.inputSchema };
});
}
/** Convert a canonical registry name to a provider-legal tool name. */
private toApiToolName(name: string): string {
let base = name.replace(/[^a-zA-Z0-9_-]/g, "_");
// Disambiguate the rare case where two canonical names sanitize alike.
if (this.toolNameMap.has(base)) {
let i = 2;
while (this.toolNameMap.has(`${base}_${i}`)) i++;
base = `${base}_${i}`;
}
return base;
}
private trimHistory(): void {
if (this.messages.length <= MAX_HISTORY_MESSAGES) return;
// Drop whole messages from the head. A leading orphan tool_result (whose
// tool_use we trimmed) would be rejected by the API, so skip past any
// tool_result-only user message left at the front.
const overflow = this.messages.length - MAX_HISTORY_MESSAGES;
this.messages.splice(0, overflow);
while (this.messages.length && startsWithToolResult(this.messages[0]!)) {
this.messages.shift();
}
}
}
function startsWithToolResult(message: LlmMessage): boolean {
return (
Array.isArray(message.content) &&
message.content.length > 0 &&
message.content[0]!.type === "tool_result"
);
}