agentus 0.4.4 → 0.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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Where a 语音总结 call should GO.
3
+ *
4
+ * The rule the operator asked for: use the provider and model this session is already running on
5
+ * — I am on `deepseek:deepseek-v4-flash`, so the summary must cost money on DeepSeek, not on a
6
+ * second provider (Bailian) that exists only because the voice pipeline needed it.
7
+ *
8
+ * Nothing is configured twice. The endpoint and the key are read from the SAME places the agent
9
+ * reads its own:
10
+ *
11
+ * 1. `<home>/config.yaml` → `providers.<id>.{base_url,api_key,model}` — a provider the operator
12
+ * declared by hand (the `lab` row is the live example), literal values;
13
+ * 2. `<home>/config.yaml` → `model_aliases.<alias>.{provider,base_url}` — a labelled shortcut;
14
+ * 3. the builtin table below + `<home>/.env` / process env — the vendors the agent knows
15
+ * without being told (DeepSeek, OpenAI, OpenRouter, …).
16
+ *
17
+ * The table is deliberately SHORT and only holds providers whose chat surface is OpenAI-compatible
18
+ * `/v1/chat/completions` (that is the only shape this call speaks). Anything else — Anthropic
19
+ * Messages, Bedrock, Vertex, OAuth-only providers — resolves to null with a reason and the caller
20
+ * falls back to the configured summary model. Silence would be the bug; a made-up endpoint is worse.
21
+ */
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { parse as parseYaml } from "yaml";
25
+
26
+ export interface SpeechRoute {
27
+ provider: string;
28
+ model: string;
29
+ /** Base URL of an OpenAI-compatible surface, no trailing slash (`…/v1`). */
30
+ baseUrl: string;
31
+ apiKey: string;
32
+ /** Where the endpoint came from — printed in the settings card and in errors. */
33
+ source: string;
34
+ }
35
+
36
+ /** Vendors the agent supports out of the box, OpenAI-compatible only.
37
+ * Mirrors `hermes_cli/auth.py`'s registry rows (id, base url, key env, base-url env); keep it in
38
+ * sync when a vendor is added there. A vendor that is NOT here still works if it is declared in
39
+ * `config.yaml` → `providers.<id>` — that is the escape hatch, not a second config in Agentus. */
40
+ const BUILTIN: Record<string, { baseUrl: string; keys: string[]; baseEnv?: string }> = {
41
+ deepseek: { baseUrl: "https://api.deepseek.com/v1", keys: ["DEEPSEEK_API_KEY"], baseEnv: "DEEPSEEK_BASE_URL" },
42
+ openai: { baseUrl: "https://api.openai.com/v1", keys: ["OPENAI_API_KEY"], baseEnv: "OPENAI_BASE_URL" },
43
+ openrouter: { baseUrl: "https://openrouter.ai/api/v1", keys: ["OPENROUTER_API_KEY"], baseEnv: "OPENROUTER_BASE_URL" },
44
+ alibaba: { baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", keys: ["DASHSCOPE_API_KEY"], baseEnv: "DASHSCOPE_BASE_URL" },
45
+ "alibaba-coding-plan": {
46
+ baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
47
+ keys: ["ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY"],
48
+ baseEnv: "ALIBABA_CODING_PLAN_BASE_URL",
49
+ },
50
+ xai: { baseUrl: "https://api.x.ai/v1", keys: ["XAI_API_KEY"], baseEnv: "XAI_BASE_URL" },
51
+ "kimi-coding-cn": { baseUrl: "https://api.moonshot.cn/v1", keys: ["KIMI_CN_API_KEY"] },
52
+ nvidia: { baseUrl: "https://integrate.api.nvidia.com/v1", keys: ["NVIDIA_API_KEY"], baseEnv: "NVIDIA_BASE_URL" },
53
+ huggingface: { baseUrl: "https://router.huggingface.co/v1", keys: ["HF_TOKEN"], baseEnv: "HF_BASE_URL" },
54
+ groq: { baseUrl: "https://api.groq.com/openai/v1", keys: ["GROQ_API_KEY"], baseEnv: "GROQ_BASE_URL" },
55
+ };
56
+
57
+ /**
58
+ * ACP model ids arrive as `deepseek:deepseek-v4-flash` (provider prefix, wire form) or
59
+ * `openrouter:anthropic/claude-x`; older agents use `provider/model`. Returns "" for a bare id
60
+ * with no prefix — the caller then has to ask config.yaml who owns it.
61
+ */
62
+ export function splitModelId(raw: string | null | undefined): { provider: string; model: string } {
63
+ const id = String(raw ?? "").trim();
64
+ if (!id) return { provider: "", model: "" };
65
+ const colon = id.indexOf(":");
66
+ if (colon > 0) return { provider: id.slice(0, colon).trim().toLowerCase(), model: id.slice(colon + 1).trim() };
67
+ return { provider: "", model: id };
68
+ }
69
+
70
+ function readEnvFile(p: string): Record<string, string> {
71
+ const out: Record<string, string> = {};
72
+ try {
73
+ for (const line of fs.readFileSync(p, "utf8").split("\n")) {
74
+ const t = line.trim();
75
+ if (!t || t.startsWith("#") || !t.includes("=")) continue;
76
+ const i = t.indexOf("=");
77
+ out[t.slice(0, i)] = t.slice(i + 1).trim().replace(/^["']|["']$/g, "");
78
+ }
79
+ } catch { /* missing/unreadable: no env file */ }
80
+ return out;
81
+ }
82
+
83
+ function readConfig(home: string | null): Record<string, unknown> {
84
+ if (!home) return {};
85
+ try {
86
+ const doc = parseYaml(fs.readFileSync(path.join(home, "config.yaml"), "utf8"));
87
+ return doc && typeof doc === "object" ? (doc as Record<string, unknown>) : {};
88
+ } catch { return {}; }
89
+ }
90
+
91
+ const asString = (v: unknown): string => (typeof v === "string" ? v.trim() : typeof v === "number" ? String(v) : "");
92
+ const trimSlash = (u: string): string => u.replace(/\/+$/, "");
93
+
94
+ /**
95
+ * Resolve the route for one session's model. `home` is the home of the backend that session runs
96
+ * on (`planFor(row).home`) — reading a DIFFERENT agent's config would hand this call a key its
97
+ * model was never entitled to use.
98
+ */
99
+ export function routeForModel(modelId: string | null | undefined, home: string | null): {
100
+ route: SpeechRoute | null;
101
+ why: string;
102
+ } {
103
+ const parsed = splitModelId(modelId);
104
+ const model = parsed.model;
105
+ if (!model) return { route: null, why: "会话还没报出模型(agent 未握手)" };
106
+
107
+ const cfg = readConfig(home);
108
+ // An alias row may rename the model as well as point at a provider (`lab-qwen38-27b` →
109
+ // `qwen38-27b`); the wire id is not the name the vendor knows.
110
+ const aliasRow = (aliases: Record<string, unknown>): Record<string, unknown> | null => {
111
+ const hit = aliases[String(modelId ?? "").trim()] ?? aliases[model];
112
+ return hit && typeof hit === "object" ? (hit as Record<string, unknown>) : null;
113
+ };
114
+ const aliasNow = aliasRow(
115
+ (cfg.model_aliases && typeof cfg.model_aliases === "object" ? cfg.model_aliases : {}) as Record<string, unknown>,
116
+ );
117
+ const aliasModel = aliasNow ? asString(aliasNow.model) : "";
118
+ const providers = (cfg.providers && typeof cfg.providers === "object" ? cfg.providers : {}) as Record<string, unknown>;
119
+ const aliases = (cfg.model_aliases && typeof cfg.model_aliases === "object" ? cfg.model_aliases : {}) as Record<string, unknown>;
120
+
121
+ // Who owns this model? The id's own prefix wins; otherwise config.yaml's default provider, and
122
+ // only when the model really is that default (a session can be running a model the default no
123
+ // longer names — then guessing the provider would send the request to the wrong vendor).
124
+ let provider = parsed.provider;
125
+ if (!provider) {
126
+ if (aliasNow) provider = asString(aliasNow.provider).toLowerCase();
127
+ }
128
+ if (!provider) {
129
+ const def = (cfg.model && typeof cfg.model === "object" ? cfg.model : {}) as Record<string, unknown>;
130
+ const defModel = asString(def.default) || asString(def.model);
131
+ if (defModel && (defModel === model || defModel.endsWith(`:${model}`))) provider = asString(def.provider).toLowerCase();
132
+ }
133
+ if (!provider) return { route: null, why: `不知道该模型属于哪个 provider(会话报的是 ${modelId ?? "空"})` };
134
+
135
+ const envFile = home ? readEnvFile(path.join(home, ".env")) : {};
136
+ const env = (name: string): string => String(process.env[name] ?? envFile[name] ?? "").trim();
137
+
138
+ // 1) declared in config.yaml by the operator — the literal values win
139
+ const declared = providers[provider] && typeof providers[provider] === "object"
140
+ ? (providers[provider] as Record<string, unknown>) : null;
141
+ if (declared) {
142
+ const baseUrl = trimSlash(asString(declared.base_url));
143
+ const apiKey = asString(declared.api_key);
144
+ const cfgModel = asString(declared.model);
145
+ if (baseUrl && apiKey) {
146
+ return {
147
+ route: { provider, model: aliasModel || model || cfgModel, baseUrl, apiKey, source: `config.yaml providers.${provider}` },
148
+ why: "",
149
+ };
150
+ }
151
+ }
152
+
153
+ // 2) an explicit alias that carries its own base_url
154
+ if (aliasNow) {
155
+ const a = aliasNow;
156
+ const baseUrl = trimSlash(asString(a.base_url));
157
+ const apiKey = env(asString(a.api_key_env) || `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`);
158
+ if (baseUrl && apiKey) {
159
+ return { route: { provider, model: aliasModel || asString(a.model) || model, baseUrl, apiKey, source: `config.yaml model_aliases` }, why: "" };
160
+ }
161
+ }
162
+
163
+ // 3) a vendor the agent knows: base url from the table (overridable by its env), key from env
164
+ const builtin = BUILTIN[provider];
165
+ if (!builtin) {
166
+ return {
167
+ route: null,
168
+ why: `provider「${provider}」没有可用的 OpenAI 兼容端点(config.yaml providers 里没声明,也不在已知列表内)`,
169
+ };
170
+ }
171
+ const baseUrl = trimSlash((builtin.baseEnv ? env(builtin.baseEnv) : "") || builtin.baseUrl);
172
+ const keyName = builtin.keys.find((k) => env(k));
173
+ if (!keyName) {
174
+ return { route: null, why: `provider「${provider}」在本机没有 key(${builtin.keys.join(" / ")} 都没找到)` };
175
+ }
176
+ const from = String(process.env[keyName] ?? "").trim() ? "process env" : `${home ?? "?"}/.env`;
177
+ return {
178
+ route: { provider, model: aliasModel || model, baseUrl, apiKey: env(keyName), source: `${keyName}(${from})` },
179
+ why: "",
180
+ };
181
+ }
@@ -1114,6 +1114,13 @@ export class Store {
1114
1114
  /** The NEWEST page, oldest-first: what a fresh view of a long slot should open on.
1115
1115
  * `hasOlder` says whether the transcript continues above it (the "load earlier"
1116
1116
  * affordance). Distinct from messagesAfter, which walks FORWARD from a replay anchor. */
1117
+ /** One stored row by its block key. 语音总结 reads the text from HERE rather than from the
1118
+ * request body: a browser must not get to decide what reaches the model, and the store's copy is
1119
+ * the one the operator actually saw. */
1120
+ messageByKey(sessionId: string, key: string): StoredMessage | undefined {
1121
+ return this.#rowByKey(sessionId, key);
1122
+ }
1123
+
1117
1124
  messagesTail(sessionId: string, limit = 500): { messages: StoredMessage[]; hasOlder: boolean } {
1118
1125
  const page = this.messagesBefore(sessionId, Number.MAX_SAFE_INTEGER, limit);
1119
1126
  return { messages: page.messages, hasOlder: page.hasMore };
@@ -206,7 +206,10 @@ export type ServerEvent =
206
206
  durationMs?: number }
207
207
  /** per-turn trace: what model/effort/mode this turn is actually running with
208
208
  * (AionUi F-DISPLAY-11 lineage — "why did it behave differently?" ) */
209
- | { t: "turn-start"; sessionId: string; trace?: TurnTrace }
209
+ /** `pid` is the id of the prompt that started this turn, copied from the client's command (issue
210
+ * #26): it is the only thing that can prove a particular frame landed. Absent for turns the page
211
+ * did not start (a call utterance, the cockpit's cross-session `send`, a resumed transcript). */
212
+ | { t: "turn-start"; sessionId: string; trace?: TurnTrace; pid?: string }
210
213
  /** A one-line status from the AGENT itself (issue #18): `👁️ Hindsight — recalled 8 memories`,
211
214
  * a compaction countdown, a provider wait. Deliberately NOT a transcript row and not persisted —
212
215
  * it describes what the process is doing NOW, so the page shows the latest one beside the running
@@ -407,7 +410,12 @@ export type ClientCommand =
407
410
  * wait for it to actually stop before sending this prompt. Call mode sends every utterance
408
411
  * this way (the operator speaking over the agent IS the interrupt), which is also what
409
412
  * removes the race between an async cancel and the next prompt. */
410
- | { t: "prompt"; sessionId: string; text: string; attachments?: PromptAttachment[]; interrupt?: boolean }
413
+ | { t: "prompt"; sessionId: string; text: string; attachments?: PromptAttachment[]; interrupt?: boolean;
414
+ /** Client-generated id for THIS prompt, echoed back on the `turn-start` it causes (issue #26).
415
+ * Without it a page can only guess whether its frame landed: a socket that dies without a FIN
416
+ * stays `OPEN` in JS, so `send()` returns true for a frame nobody received and the operator's
417
+ * words vanish with no error. With it, "the server started a turn for prompt X" is a fact. */
418
+ pid?: string }
411
419
  | { t: "cancel"; sessionId: string }
412
420
  | { t: "set-mode"; sessionId: string; modeId: string }
413
421
  | { t: "set-config"; sessionId: string; configId: string; value: string | number | boolean }