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.
- package/package.json +7 -2
- package/packages/server/mock/agent.mjs +6 -0
- package/packages/server/package.json +2 -1
- package/packages/server/src/acp/session-manager.ts +7 -1
- package/packages/server/src/dashscope.ts +82 -0
- package/packages/server/src/index.ts +179 -10
- package/packages/server/src/observer/hub.ts +241 -19
- package/packages/server/src/settings.ts +159 -2
- package/packages/server/src/speech/route.ts +181 -0
- package/packages/server/src/store/store.ts +7 -0
- package/packages/shared/src/index.ts +10 -2
- package/packages/web/dist/assets/index-SSGsR6n4.js +127 -0
- package/packages/web/dist/assets/index-xZyah18L.css +32 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-DwdrqRgR.js +0 -125
- package/packages/web/dist/assets/index-xlniZD2Q.css +0 -32
|
@@ -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
|
-
|
|
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 }
|