mercury-agent 0.13.0 → 0.14.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/docs/container-lifecycle.md +4 -3
- package/docs/permissions.md +3 -1
- package/examples/extensions/gws/README.md +25 -6
- package/package.json +1 -1
- package/resources/templates/AGENTS.md +8 -0
- package/src/adapters/whatsapp.ts +4 -1
- package/src/bridges/whatsapp.ts +6 -14
- package/src/cli/mrctl.ts +44 -0
- package/src/core/api.ts +2 -0
- package/src/core/model-command.ts +55 -0
- package/src/core/permissions.ts +8 -1
- package/src/core/routes/index.ts +1 -0
- package/src/core/routes/model.ts +68 -0
- package/src/core/runtime.ts +31 -18
- package/src/core/system-messages.ts +6 -4
|
@@ -96,8 +96,9 @@ Containers have a maximum runtime to prevent runaway processes.
|
|
|
96
96
|
When a container exceeds the timeout:
|
|
97
97
|
1. Container is killed via `docker kill`
|
|
98
98
|
2. `ContainerError` thrown with `reason: "timeout"`
|
|
99
|
-
3. User sees: "
|
|
100
|
-
4.
|
|
99
|
+
3. User sees: "I ran out of time before finishing. Partial work may be saved in the workspace — ask me to continue."
|
|
100
|
+
4. A marker assistant message ("[System: this run was killed at the container time limit before replying. …]") is recorded in chat history, so the next run's agent knows the previous run was cut off and can check the workspace for partial work
|
|
101
|
+
5. Queue unblocks, next message can proceed
|
|
101
102
|
|
|
102
103
|
The host always injects a resolved **model chain** into the container (after `MERCURY_*` passthrough) so retries and fallbacks use the same policy Mercury loaded at startup:
|
|
103
104
|
|
|
@@ -113,7 +114,7 @@ Container failures are classified by `ContainerError`:
|
|
|
113
114
|
|
|
114
115
|
| Reason | Exit Code | Cause | User Message |
|
|
115
116
|
|--------|-----------|-------|--------------|
|
|
116
|
-
| `timeout` | — | Exceeded `containerTimeoutMs` | "
|
|
117
|
+
| `timeout` | — | Exceeded `containerTimeoutMs` | "I ran out of time before finishing. Partial work may be saved in the workspace — ask me to continue." |
|
|
117
118
|
| `oom` | 137 | SIGKILL (OOM, resource limits, or manual kill) | "Container was killed (possibly out of memory)." |
|
|
118
119
|
| `aborted` | — | User sent `stop` command | "Stopped current run." |
|
|
119
120
|
| `error` | non-zero | Agent crashed or failed | *(error thrown, logged)* |
|
package/docs/permissions.md
CHANGED
|
@@ -28,7 +28,7 @@ Message arrives
|
|
|
28
28
|
|------|---------------------|-------------|
|
|
29
29
|
| `system` | All | Internal system caller (scheduler, etc.) — not assignable |
|
|
30
30
|
| `admin` | All | Full control over the space |
|
|
31
|
-
| `member` | `prompt`, `prefs.get`, `media.receive`, `media.send` | Can chat, read space preferences,
|
|
31
|
+
| `member` | `prompt`, `prefs.get`, `media.receive`, `media.send`, `model.list` | Can chat, read space preferences, exchange files, and list models (default for new users) |
|
|
32
32
|
|
|
33
33
|
Custom roles can be created by assigning permissions to any role name.
|
|
34
34
|
|
|
@@ -56,6 +56,8 @@ Custom roles can be created by assigning permissions to any role name.
|
|
|
56
56
|
| `spaces.list` | View all spaces |
|
|
57
57
|
| `spaces.rename` | Rename a space and link/unlink conversations |
|
|
58
58
|
| `spaces.delete` | Delete current space and all related DB data |
|
|
59
|
+
| `model.list` | List the configured model chain (`mrctl model list`, chat `/model list`) |
|
|
60
|
+
| `model.switch` | Switch the active model for the space (`mrctl model switch`, chat `/model switch`) — takes effect on the next agent run |
|
|
59
61
|
| `media.receive` | Incoming attachments are saved to `inbox/` and shown to the agent |
|
|
60
62
|
| `media.send` | Outbox files produced on this caller's turn are delivered back to the chat |
|
|
61
63
|
|
|
@@ -28,19 +28,38 @@ npm i -g @googleworkspace/cli
|
|
|
28
28
|
gws auth login
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
2. Export credentials
|
|
31
|
+
2. Export the credentials and put the JSON (as a single line) into `.env`:
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
gws auth export --unmasked
|
|
34
|
+
gws auth export --unmasked
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
3. Add to `.env`:
|
|
38
|
-
|
|
39
37
|
```bash
|
|
40
|
-
|
|
38
|
+
MERCURY_GWS_CREDENTIALS_JSON={"client_id":"...","client_secret":"...","refresh_token":"...","type":"authorized_user"}
|
|
41
39
|
```
|
|
42
40
|
|
|
43
|
-
|
|
41
|
+
3. Restart Mercury. The extension's `before_container` hook passes the JSON
|
|
42
|
+
into the inner container, and the skill materializes it as a credentials
|
|
43
|
+
file at runtime.
|
|
44
|
+
|
|
45
|
+
> **Do not use the file-based path.** Older versions of this README said to
|
|
46
|
+
> export to `.mercury/global/gws-credentials.json` and set
|
|
47
|
+
> `MERCURY_GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE`. That no longer works: the
|
|
48
|
+
> runtime mounts the global dir into the inner container entry-by-entry via an
|
|
49
|
+
> allowlist (`PI_AGENT_RESOURCE_ENTRIES` in `container-runner.ts`), and the
|
|
50
|
+
> credentials file is not on it — the container never sees it.
|
|
51
|
+
>
|
|
52
|
+
> **Windows/PowerShell note:** if you do redirect `gws auth export` output to a
|
|
53
|
+
> file, PowerShell's `>` writes UTF-16 — invalid for JSON readers. Use
|
|
54
|
+
> `gws auth export --unmasked | Out-File -Encoding utf8 <path>` instead.
|
|
55
|
+
>
|
|
56
|
+
> (`MERCURY_GOOGLE_WORKSPACE_CLI_TOKEN` also exists, but it expires quickly.)
|
|
57
|
+
|
|
58
|
+
## Refreshing credentials
|
|
59
|
+
|
|
60
|
+
Re-run `gws auth login` + `gws auth export --unmasked`, replace the
|
|
61
|
+
`MERCURY_GWS_CREDENTIALS_JSON` value in `.env` (the refresh token changes),
|
|
62
|
+
and restart Mercury.
|
|
44
63
|
|
|
45
64
|
## Verify
|
|
46
65
|
|
package/package.json
CHANGED
|
@@ -53,6 +53,14 @@ mrctl config set <key> <value> # Set config value
|
|
|
53
53
|
# context.mode (clear|context), context.reply_chain_depth (1-50)
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
### Models
|
|
57
|
+
```bash
|
|
58
|
+
mrctl model list # List configured models (active one marked)
|
|
59
|
+
mrctl model switch <N|MODEL_ID> # Switch active model — takes effect on the NEXT agent run
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Note:** A switch never changes the run you are in — say so when reporting it. Switching requires the `model.switch` permission (admin by default); a 403 means the caller must ask an admin.
|
|
63
|
+
|
|
56
64
|
### Spaces
|
|
57
65
|
```bash
|
|
58
66
|
mrctl spaces list # List all spaces with names (admin-only)
|
package/src/adapters/whatsapp.ts
CHANGED
|
@@ -63,7 +63,7 @@ function extractText(rawMessage?: proto.IMessage | null): string {
|
|
|
63
63
|
);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
function getContextInfo(
|
|
66
|
+
export function getContextInfo(
|
|
67
67
|
rawMessage?: proto.IMessage | null,
|
|
68
68
|
): proto.IContextInfo | undefined {
|
|
69
69
|
const message = normalizeMessageContent(rawMessage ?? undefined);
|
|
@@ -72,6 +72,9 @@ function getContextInfo(
|
|
|
72
72
|
message.extendedTextMessage?.contextInfo ||
|
|
73
73
|
message.imageMessage?.contextInfo ||
|
|
74
74
|
message.videoMessage?.contextInfo ||
|
|
75
|
+
message.ptvMessage?.contextInfo ||
|
|
76
|
+
message.audioMessage?.contextInfo ||
|
|
77
|
+
message.stickerMessage?.contextInfo ||
|
|
75
78
|
message.documentMessage?.contextInfo ||
|
|
76
79
|
message.buttonsResponseMessage?.contextInfo ||
|
|
77
80
|
message.templateButtonReplyMessage?.contextInfo ||
|
package/src/bridges/whatsapp.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
normalizeMessageContent,
|
|
5
|
-
type proto,
|
|
6
|
-
type WAMessage,
|
|
7
|
-
} from "@whiskeysockets/baileys";
|
|
3
|
+
import type { proto, WAMessage } from "@whiskeysockets/baileys";
|
|
8
4
|
import type { Message } from "chat";
|
|
9
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
getContextInfo,
|
|
7
|
+
type WhatsAppBaileysAdapter,
|
|
8
|
+
} from "../adapters/whatsapp.js";
|
|
10
9
|
import {
|
|
11
10
|
detectWhatsAppMedia,
|
|
12
11
|
downloadQuotedMedia,
|
|
@@ -97,14 +96,7 @@ export class WhatsAppBridge implements PlatformBridge {
|
|
|
97
96
|
}
|
|
98
97
|
|
|
99
98
|
if (attachments.length === 0) {
|
|
100
|
-
const
|
|
101
|
-
const contextInfo =
|
|
102
|
-
content?.extendedTextMessage?.contextInfo ||
|
|
103
|
-
content?.audioMessage?.contextInfo ||
|
|
104
|
-
content?.imageMessage?.contextInfo ||
|
|
105
|
-
content?.videoMessage?.contextInfo ||
|
|
106
|
-
content?.documentMessage?.contextInfo ||
|
|
107
|
-
content?.stickerMessage?.contextInfo;
|
|
99
|
+
const contextInfo = getContextInfo(rawMsg.message);
|
|
108
100
|
if (contextInfo?.quotedMessage) {
|
|
109
101
|
if (await ctx.isOverQuota()) {
|
|
110
102
|
logger.warn(
|
package/src/cli/mrctl.ts
CHANGED
|
@@ -82,6 +82,7 @@ Built-in commands:
|
|
|
82
82
|
mrctl prefs list|get|set|delete
|
|
83
83
|
mrctl roles list|grant|revoke
|
|
84
84
|
mrctl permissions show|set
|
|
85
|
+
mrctl model list|switch <N|MODEL_ID> (switch takes effect on the NEXT agent run)
|
|
85
86
|
mrctl spaces list|name [<name>]|delete (delete: CURRENT space only, takes no argument)
|
|
86
87
|
mrctl conversations list
|
|
87
88
|
mrctl mute <platform-user-id> <duration> [--reason <reason>]
|
|
@@ -405,6 +406,49 @@ async function main() {
|
|
|
405
406
|
break;
|
|
406
407
|
}
|
|
407
408
|
|
|
409
|
+
case "model": {
|
|
410
|
+
if (!sub) usage();
|
|
411
|
+
switch (sub) {
|
|
412
|
+
case "list": {
|
|
413
|
+
const data = (await api("GET", "/api/model")) as {
|
|
414
|
+
models: Array<{
|
|
415
|
+
index: number;
|
|
416
|
+
provider: string;
|
|
417
|
+
model: string;
|
|
418
|
+
active: boolean;
|
|
419
|
+
}>;
|
|
420
|
+
};
|
|
421
|
+
if (data.models.length === 0) {
|
|
422
|
+
process.stdout.write("No models configured.\n");
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
// Same shape as chat /model list so the agent can relay it verbatim
|
|
426
|
+
process.stdout.write("Configured models:\n");
|
|
427
|
+
for (const m of data.models) {
|
|
428
|
+
process.stdout.write(
|
|
429
|
+
` [${m.index}] ${m.provider} / ${m.model}${m.active ? " ← active" : ""}\n`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
case "switch": {
|
|
435
|
+
const target = requireArg(args, 2, "target (<N|MODEL_ID>)");
|
|
436
|
+
const data = (await api("POST", "/api/model/switch", { target })) as {
|
|
437
|
+
provider: string;
|
|
438
|
+
model: string;
|
|
439
|
+
note: string;
|
|
440
|
+
};
|
|
441
|
+
process.stdout.write(
|
|
442
|
+
`Switched to ${data.provider} / ${data.model}. ${data.note}\n`,
|
|
443
|
+
);
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
default:
|
|
447
|
+
fatal(`Unknown model subcommand: ${sub}`);
|
|
448
|
+
}
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
|
|
408
452
|
case "conversations": {
|
|
409
453
|
const action = sub ?? "list";
|
|
410
454
|
switch (action) {
|
package/src/core/api.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
extensions,
|
|
16
16
|
media,
|
|
17
17
|
messages,
|
|
18
|
+
model,
|
|
18
19
|
mutes,
|
|
19
20
|
permissions,
|
|
20
21
|
prefs,
|
|
@@ -114,6 +115,7 @@ export function createApiApp(apiCtx: ApiContext): Hono<Env> {
|
|
|
114
115
|
app.route("/conversations", conversations);
|
|
115
116
|
app.route("/media", media);
|
|
116
117
|
app.route("/messages", messages);
|
|
118
|
+
app.route("/model", model);
|
|
117
119
|
app.route("/mutes", mutes);
|
|
118
120
|
app.route("/ext", extensions);
|
|
119
121
|
app.route("/connections", connections);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ModelLeg } from "../config.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared resolution logic for the model chain — the single source of truth
|
|
5
|
+
* behind both the chat `/model list|switch` command (runtime.ts) and the
|
|
6
|
+
* `/api/model` route (routes/model.ts). Semantics live here so the two
|
|
7
|
+
* surfaces cannot drift.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Index of the active leg in the chain: the leg named by the stored
|
|
12
|
+
* `model.active` value (`provider:model`) when it still exists in the chain,
|
|
13
|
+
* otherwise leg 0. A stale value (host config changed since the switch) falls
|
|
14
|
+
* back to the primary leg, matching what container start does when it ignores
|
|
15
|
+
* an unknown override.
|
|
16
|
+
*
|
|
17
|
+
* Returns -1 only for an empty chain.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveActiveIndex(
|
|
20
|
+
chain: ModelLeg[],
|
|
21
|
+
activeRaw: string | null,
|
|
22
|
+
): number {
|
|
23
|
+
if (chain.length === 0) return -1;
|
|
24
|
+
if (activeRaw) {
|
|
25
|
+
const idx = chain.findIndex(
|
|
26
|
+
(l) => `${l.provider}:${l.model}` === activeRaw,
|
|
27
|
+
);
|
|
28
|
+
if (idx !== -1) return idx;
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a switch target to a chain leg, or null if it names none.
|
|
35
|
+
* Accepted forms, in resolution order:
|
|
36
|
+
* 1. 1-based index into the chain ("2") — numeric parse wins over a model
|
|
37
|
+
* literally named like an in-range number
|
|
38
|
+
* 2. bare model id ("claude-opus-4-8")
|
|
39
|
+
* 3. provider-qualified id ("anthropic:claude-opus-4-8")
|
|
40
|
+
*/
|
|
41
|
+
export function resolveSwitchTarget(
|
|
42
|
+
chain: ModelLeg[],
|
|
43
|
+
arg: string,
|
|
44
|
+
): ModelLeg | null {
|
|
45
|
+
// Strict digits only: parseInt("2junk") is 2, and a typo silently switching
|
|
46
|
+
// to a real leg is exactly the failure this validated path exists to prevent.
|
|
47
|
+
if (/^\d+$/.test(arg)) {
|
|
48
|
+
const num = Number.parseInt(arg, 10);
|
|
49
|
+
if (num >= 1 && num <= chain.length) return chain[num - 1];
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
chain.find((l) => l.model === arg || `${l.provider}:${l.model}` === arg) ??
|
|
53
|
+
null
|
|
54
|
+
);
|
|
55
|
+
}
|
package/src/core/permissions.ts
CHANGED
|
@@ -34,6 +34,10 @@ const BUILT_IN_PERMISSIONS = new Set([
|
|
|
34
34
|
"media.receive",
|
|
35
35
|
/** Outbox files produced on this caller's turn are delivered back. */
|
|
36
36
|
"media.send",
|
|
37
|
+
/** List the configured model chain (/api/model, chat /model list). */
|
|
38
|
+
"model.list",
|
|
39
|
+
/** Switch the active model (/api/model/switch, chat /model switch); admin-only by default. */
|
|
40
|
+
"model.switch",
|
|
37
41
|
/** Host Text-to-Speech (/api/tts); admin-only by default. */
|
|
38
42
|
"tts.synthesize",
|
|
39
43
|
/** Mute/unmute users and list mutes; admin-only by default. */
|
|
@@ -194,6 +198,9 @@ const DEFAULT_MEMBER_PERMISSIONS = new Set([
|
|
|
194
198
|
"prefs.get",
|
|
195
199
|
"media.receive",
|
|
196
200
|
"media.send",
|
|
201
|
+
// Read-only, like prefs.get: lets the agent answer "which models do I have?"
|
|
202
|
+
// for any caller. model.switch is deliberately absent — admin-only default.
|
|
203
|
+
"model.list",
|
|
197
204
|
]);
|
|
198
205
|
|
|
199
206
|
/**
|
|
@@ -201,7 +208,7 @@ const DEFAULT_MEMBER_PERMISSIONS = new Set([
|
|
|
201
208
|
* with extension-registered defaults.
|
|
202
209
|
*
|
|
203
210
|
* - `admin` and `system` get all permissions (built-in + extension)
|
|
204
|
-
* - `member` gets `prompt`, `prefs.get`, `media.receive`, `media.send`, plus any extension permissions that list "member" in defaultRoles
|
|
211
|
+
* - `member` gets `prompt`, `prefs.get`, `media.receive`, `media.send`, `model.list`, plus any extension permissions that list "member" in defaultRoles
|
|
205
212
|
* - Other roles get extension permissions that list them in defaultRoles
|
|
206
213
|
*/
|
|
207
214
|
function getDefaultPermissions(role: string): Set<string> {
|
package/src/core/routes/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export { conversations } from "./conversations.js";
|
|
|
8
8
|
export { extensions } from "./extensions.js";
|
|
9
9
|
export { media } from "./media.js";
|
|
10
10
|
export { messages } from "./messages.js";
|
|
11
|
+
export { model } from "./model.js";
|
|
11
12
|
export { mutes } from "./mutes.js";
|
|
12
13
|
export { prefs } from "./prefs.js";
|
|
13
14
|
export { permissions, roles } from "./roles.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { checkPerm, type Env, getApiCtx, getAuth } from "../api-types.js";
|
|
3
|
+
import { resolveActiveIndex, resolveSwitchTarget } from "../model-command.js";
|
|
4
|
+
|
|
5
|
+
export const model = new Hono<Env>();
|
|
6
|
+
|
|
7
|
+
model.get("/", (c) => {
|
|
8
|
+
const { spaceId } = getAuth(c);
|
|
9
|
+
const denied = checkPerm(c, "model.list");
|
|
10
|
+
if (denied) return denied;
|
|
11
|
+
|
|
12
|
+
const { db, config } = getApiCtx(c);
|
|
13
|
+
const chain = config.resolvedModelChain;
|
|
14
|
+
const activeIdx = resolveActiveIndex(
|
|
15
|
+
chain,
|
|
16
|
+
db.getSpaceConfig(spaceId, "model.active"),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
return c.json({
|
|
20
|
+
models: chain.map((leg, i) => ({
|
|
21
|
+
// 1-based to match chat /model list and the switch target form
|
|
22
|
+
index: i + 1,
|
|
23
|
+
provider: leg.provider,
|
|
24
|
+
model: leg.model,
|
|
25
|
+
active: i === activeIdx,
|
|
26
|
+
})),
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
model.post("/switch", async (c) => {
|
|
31
|
+
const { spaceId, callerId } = getAuth(c);
|
|
32
|
+
const denied = checkPerm(c, "model.switch");
|
|
33
|
+
if (denied) return denied;
|
|
34
|
+
|
|
35
|
+
const { db, config } = getApiCtx(c);
|
|
36
|
+
const body = await c.req
|
|
37
|
+
.json<{ target?: string }>()
|
|
38
|
+
.catch(() => ({}) as { target?: string });
|
|
39
|
+
|
|
40
|
+
const target = typeof body.target === "string" ? body.target.trim() : "";
|
|
41
|
+
if (!target) return c.json({ error: "Missing target" }, 400);
|
|
42
|
+
|
|
43
|
+
const chain = config.resolvedModelChain;
|
|
44
|
+
if (chain.length === 0) {
|
|
45
|
+
return c.json({ error: "No models configured." }, 400);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const leg = resolveSwitchTarget(chain, target);
|
|
49
|
+
if (!leg) {
|
|
50
|
+
return c.json(
|
|
51
|
+
{ error: "Model not found. Use mrctl model list to see your options." },
|
|
52
|
+
400,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
db.setSpaceConfig(
|
|
57
|
+
spaceId,
|
|
58
|
+
"model.active",
|
|
59
|
+
`${leg.provider}:${leg.model}`,
|
|
60
|
+
callerId,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return c.json({
|
|
64
|
+
provider: leg.provider,
|
|
65
|
+
model: leg.model,
|
|
66
|
+
note: "Takes effect on the next agent run; the current run keeps its model.",
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/core/runtime.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
resolveRecipientSpaceId,
|
|
34
34
|
} from "./direct-send.js";
|
|
35
35
|
import { gateIncomingMedia, gateOutgoingMedia } from "./media-gate.js";
|
|
36
|
+
import { resolveActiveIndex, resolveSwitchTarget } from "./model-command.js";
|
|
36
37
|
import { hasPermission, resolveRole } from "./permissions.js";
|
|
37
38
|
import { getActiveProfilePrompt } from "./profiles.js";
|
|
38
39
|
import { RateLimiter } from "./rate-limiter.js";
|
|
@@ -587,6 +588,7 @@ export class MercuryCoreRuntime {
|
|
|
587
588
|
message.spaceId,
|
|
588
589
|
route.command,
|
|
589
590
|
route.callerId,
|
|
591
|
+
route.role,
|
|
590
592
|
route.verb,
|
|
591
593
|
route.arg,
|
|
592
594
|
{
|
|
@@ -803,6 +805,7 @@ export class MercuryCoreRuntime {
|
|
|
803
805
|
spaceId: string,
|
|
804
806
|
command: string,
|
|
805
807
|
callerId: string,
|
|
808
|
+
role: string,
|
|
806
809
|
verb?: string,
|
|
807
810
|
arg?: string,
|
|
808
811
|
conversationContext?: { platform: string; externalId: string },
|
|
@@ -833,7 +836,7 @@ export class MercuryCoreRuntime {
|
|
|
833
836
|
return formatHelp();
|
|
834
837
|
}
|
|
835
838
|
case "model":
|
|
836
|
-
return this.executeModelsCommand(spaceId, callerId, verb, arg);
|
|
839
|
+
return this.executeModelsCommand(spaceId, callerId, role, verb, arg);
|
|
837
840
|
case "spaces":
|
|
838
841
|
return this.executeSpacesCommand(
|
|
839
842
|
spaceId,
|
|
@@ -854,6 +857,7 @@ export class MercuryCoreRuntime {
|
|
|
854
857
|
private executeModelsCommand(
|
|
855
858
|
spaceId: string,
|
|
856
859
|
callerId: string,
|
|
860
|
+
role: string,
|
|
857
861
|
verb?: string,
|
|
858
862
|
arg?: string,
|
|
859
863
|
): string {
|
|
@@ -865,19 +869,18 @@ export class MercuryCoreRuntime {
|
|
|
865
869
|
|
|
866
870
|
switch (verb) {
|
|
867
871
|
case "list": {
|
|
872
|
+
// Deliberately NOT gated on model.list: chat list is read-only, members
|
|
873
|
+
// hold model.list by default anyway, and pre-gate behavior is kept
|
|
874
|
+
// byte-identical. Only the API surface enforces model.list (an admin
|
|
875
|
+
// revoking it blocks mrctl/API listing but not chat).
|
|
868
876
|
if (chain.length === 0) return "No models configured.";
|
|
869
877
|
const activeRaw = this.db.getSpaceConfig(spaceId, "model.active");
|
|
870
|
-
const
|
|
871
|
-
? chain.some((l) => `${l.provider}:${l.model}` === activeRaw)
|
|
872
|
-
: false;
|
|
878
|
+
const activeIdx = resolveActiveIndex(chain, activeRaw);
|
|
873
879
|
const lines = ["Configured models:"];
|
|
874
880
|
for (let i = 0; i < chain.length; i++) {
|
|
875
881
|
const leg = chain[i];
|
|
876
|
-
const isActive = activeFound
|
|
877
|
-
? activeRaw === `${leg.provider}:${leg.model}`
|
|
878
|
-
: i === 0;
|
|
879
882
|
lines.push(
|
|
880
|
-
` [${i + 1}] ${leg.provider} / ${leg.model}${
|
|
883
|
+
` [${i + 1}] ${leg.provider} / ${leg.model}${i === activeIdx ? " ← active" : ""}`,
|
|
881
884
|
);
|
|
882
885
|
}
|
|
883
886
|
return lines.join("\n");
|
|
@@ -890,20 +893,17 @@ export class MercuryCoreRuntime {
|
|
|
890
893
|
return "`/model active` has been removed — `/model list` marks the active model.";
|
|
891
894
|
|
|
892
895
|
case "switch": {
|
|
896
|
+
// The router only admin-gates slash commands in GROUP chats; in DMs any
|
|
897
|
+
// prompt-holder reaches this point. Same permission as /api/model/switch
|
|
898
|
+
// so the two surfaces cannot disagree.
|
|
899
|
+
if (!hasPermission(this.db, spaceId, role, "model.switch")) {
|
|
900
|
+
return "Switching models requires the 'model.switch' permission — ask an admin.";
|
|
901
|
+
}
|
|
893
902
|
if (!arg)
|
|
894
903
|
return "Usage: /model switch <N|MODEL_ID>. Use /model list to see options.";
|
|
895
904
|
if (chain.length === 0) return "No models configured.";
|
|
896
905
|
|
|
897
|
-
|
|
898
|
-
const num = Number.parseInt(arg, 10);
|
|
899
|
-
if (!Number.isNaN(num) && num >= 1 && num <= chain.length) {
|
|
900
|
-
leg = chain[num - 1];
|
|
901
|
-
} else {
|
|
902
|
-
leg = chain.find(
|
|
903
|
-
(l) => l.model === arg || `${l.provider}:${l.model}` === arg,
|
|
904
|
-
);
|
|
905
|
-
}
|
|
906
|
-
|
|
906
|
+
const leg = resolveSwitchTarget(chain, arg);
|
|
907
907
|
if (!leg)
|
|
908
908
|
return "Model not found. Use /model list to see your options.";
|
|
909
909
|
|
|
@@ -1870,6 +1870,19 @@ export class MercuryCoreRuntime {
|
|
|
1870
1870
|
});
|
|
1871
1871
|
} catch (err) {
|
|
1872
1872
|
this.db.updateMessageRunMeta(userMessageId, userTurnRunMeta(undefined));
|
|
1873
|
+
// Timeout kills the container mid-task with no assistant reply, so the
|
|
1874
|
+
// next run's history would show an unanswered turn with no explanation.
|
|
1875
|
+
// Record why, so the agent knows the run was cut off rather than
|
|
1876
|
+
// guessing — workspace files written before the kill survive.
|
|
1877
|
+
if (err instanceof ContainerError && err.reason === "timeout") {
|
|
1878
|
+
this.db.addMessage(
|
|
1879
|
+
spaceId,
|
|
1880
|
+
"assistant",
|
|
1881
|
+
"[System: this run was killed at the container time limit before replying. Partial work may exist in the workspace.]",
|
|
1882
|
+
undefined,
|
|
1883
|
+
userMessageId,
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1873
1886
|
throw err;
|
|
1874
1887
|
}
|
|
1875
1888
|
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* space switch them to another locale via the `messages.locale` config key.
|
|
9
9
|
*
|
|
10
10
|
* Resolution chain: per-space → `@global` → `config.messagesLocale` → `"en"`.
|
|
11
|
-
* The `en` strings are byte-
|
|
12
|
-
*
|
|
11
|
+
* The `en` strings are pinned byte-for-byte by tests — downstream matching
|
|
12
|
+
* depends on them, so change them deliberately and update the test together.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { AppConfig } from "../config.js";
|
|
@@ -63,7 +63,8 @@ export const SYSTEM_MESSAGES: Record<
|
|
|
63
63
|
attachment_failed:
|
|
64
64
|
"Could not use your attachment (media disabled, over the size limit, or download failed). Check MERCURY_MEDIA_ENABLED and logs.",
|
|
65
65
|
run_stopped: "Stopped current run.",
|
|
66
|
-
container_timeout:
|
|
66
|
+
container_timeout:
|
|
67
|
+
"I ran out of time before finishing. Partial work may be saved in the workspace — ask me to continue.",
|
|
67
68
|
container_oom: "Container was killed (possibly out of memory).",
|
|
68
69
|
no_permission_prompt:
|
|
69
70
|
"You don't have permission to use the agent in this group.",
|
|
@@ -114,7 +115,8 @@ export const SYSTEM_MESSAGES: Record<
|
|
|
114
115
|
attachment_failed:
|
|
115
116
|
"לא ניתן היה להשתמש בקובץ המצורף (מדיה מושבתת, חריגה ממגבלת הגודל, או שההורדה נכשלה). בדקו את MERCURY_MEDIA_ENABLED ואת הלוגים.",
|
|
116
117
|
run_stopped: "הריצה הנוכחית הופסקה.",
|
|
117
|
-
container_timeout:
|
|
118
|
+
container_timeout:
|
|
119
|
+
"נגמר לי הזמן לפני שסיימתי. ייתכן שעבודה חלקית נשמרה בסביבת העבודה — בקשו ממני להמשיך.",
|
|
118
120
|
container_oom: "הקונטיינר הופסק (ייתכן שבשל מחסור בזיכרון).",
|
|
119
121
|
no_permission_prompt: "אין לכם הרשאה להשתמש בסוכן בקבוצה זו.",
|
|
120
122
|
no_permission_slash: "אין לכם הרשאה להשתמש ב-'/{command}'.",
|