aismemory 0.7.4 → 0.8.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/README.md +211 -11
- package/dist/__tests__/agent-archive.test.d.ts +1 -0
- package/dist/__tests__/agent-archive.test.js +153 -0
- package/dist/__tests__/agent-archive.test.js.map +1 -0
- package/dist/__tests__/agent-key-mode.test.js +88 -7
- package/dist/__tests__/agent-key-mode.test.js.map +1 -1
- package/dist/__tests__/ais-http.test.js +32 -0
- package/dist/__tests__/ais-http.test.js.map +1 -1
- package/dist/__tests__/credential-isolation.test.js +117 -3
- package/dist/__tests__/credential-isolation.test.js.map +1 -1
- package/dist/__tests__/credential-resolution.test.js +5 -5
- package/dist/__tests__/credential-resolution.test.js.map +1 -1
- package/dist/__tests__/device-flow-gate-e2e.test.js +5 -0
- package/dist/__tests__/device-flow-gate-e2e.test.js.map +1 -1
- package/dist/__tests__/doctor.test.js +144 -79
- package/dist/__tests__/doctor.test.js.map +1 -1
- package/dist/__tests__/mcp-tools.test.d.ts +1 -0
- package/dist/__tests__/mcp-tools.test.js +189 -0
- package/dist/__tests__/mcp-tools.test.js.map +1 -0
- package/dist/__tests__/memory-pull.test.d.ts +1 -0
- package/dist/__tests__/memory-pull.test.js +380 -0
- package/dist/__tests__/memory-pull.test.js.map +1 -0
- package/dist/__tests__/memory-write-failure.test.js +4 -8
- package/dist/__tests__/memory-write-failure.test.js.map +1 -1
- package/dist/__tests__/no-registered-keys-disk-bearer-fallback.test.js +4 -7
- package/dist/__tests__/no-registered-keys-disk-bearer-fallback.test.js.map +1 -1
- package/dist/__tests__/pending-device-flow-e2e.test.d.ts +19 -0
- package/dist/__tests__/pending-device-flow-e2e.test.js +159 -0
- package/dist/__tests__/pending-device-flow-e2e.test.js.map +1 -0
- package/dist/__tests__/pending-device-flow.test.d.ts +15 -0
- package/dist/__tests__/pending-device-flow.test.js +108 -0
- package/dist/__tests__/pending-device-flow.test.js.map +1 -0
- package/dist/__tests__/provisional-register-mcp-remember.test.js +4 -8
- package/dist/__tests__/provisional-register-mcp-remember.test.js.map +1 -1
- package/dist/__tests__/readme-quickstart-default-ais-url.test.d.ts +1 -0
- package/dist/__tests__/readme-quickstart-default-ais-url.test.js +80 -0
- package/dist/__tests__/readme-quickstart-default-ais-url.test.js.map +1 -0
- package/dist/__tests__/reconnect-survival-e2e.test.js +3 -4
- package/dist/__tests__/reconnect-survival-e2e.test.js.map +1 -1
- package/dist/__tests__/remember-duplicate-truth.test.d.ts +13 -0
- package/dist/__tests__/remember-duplicate-truth.test.js +106 -0
- package/dist/__tests__/remember-duplicate-truth.test.js.map +1 -0
- package/dist/__tests__/retry-after.test.d.ts +4 -0
- package/dist/__tests__/retry-after.test.js +128 -0
- package/dist/__tests__/retry-after.test.js.map +1 -0
- package/dist/__tests__/startup-credential-warning-e2e.test.d.ts +1 -0
- package/dist/__tests__/startup-credential-warning-e2e.test.js +102 -0
- package/dist/__tests__/startup-credential-warning-e2e.test.js.map +1 -0
- package/dist/__tests__/tool-args.test.js +10 -0
- package/dist/__tests__/tool-args.test.js.map +1 -1
- package/dist/__tests__/version-check.test.d.ts +1 -0
- package/dist/__tests__/version-check.test.js +349 -0
- package/dist/__tests__/version-check.test.js.map +1 -0
- package/dist/agent-archive.d.ts +36 -0
- package/dist/agent-archive.js +69 -0
- package/dist/agent-archive.js.map +1 -0
- package/dist/agent-key-mode.d.ts +1 -1
- package/dist/agent-key-mode.js +56 -19
- package/dist/agent-key-mode.js.map +1 -1
- package/dist/ais-http-error.d.ts +26 -2
- package/dist/ais-http-error.js +52 -4
- package/dist/ais-http-error.js.map +1 -1
- package/dist/ais-http.js +46 -16
- package/dist/ais-http.js.map +1 -1
- package/dist/cli/doctor.d.ts +17 -2
- package/dist/cli/doctor.js +93 -14
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/pull-memory.d.ts +17 -0
- package/dist/cli/pull-memory.js +141 -0
- package/dist/cli/pull-memory.js.map +1 -0
- package/dist/index.js +135 -398
- package/dist/index.js.map +1 -1
- package/dist/mcp/tools.d.ts +82 -0
- package/dist/mcp/tools.js +453 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/memory-pull.d.ts +114 -0
- package/dist/memory-pull.js +171 -0
- package/dist/memory-pull.js.map +1 -0
- package/dist/pending-device-flow.d.ts +49 -0
- package/dist/pending-device-flow.js +96 -0
- package/dist/pending-device-flow.js.map +1 -0
- package/dist/retry-after.d.ts +26 -0
- package/dist/retry-after.js +42 -0
- package/dist/retry-after.js.map +1 -0
- package/dist/session-identity.d.ts +26 -0
- package/dist/session-identity.js +89 -0
- package/dist/session-identity.js.map +1 -0
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.js.map +1 -1
- package/dist/tool-args.d.ts +4 -0
- package/dist/tool-args.js +16 -0
- package/dist/tool-args.js.map +1 -1
- package/dist/version-check.d.ts +72 -0
- package/dist/version-check.js +237 -0
- package/dist/version-check.js.map +1 -0
- package/dist/workspace-key-mode.js +22 -6
- package/dist/workspace-key-mode.js.map +1 -1
- package/package.json +4 -3
- package/dist/harness-identity.d.ts +0 -17
- package/dist/harness-identity.js +0 -105
- package/dist/harness-identity.js.map +0 -1
package/README.md
CHANGED
|
@@ -4,6 +4,10 @@ Persistent memory for AI agents. One command to give your AI agent memory that p
|
|
|
4
4
|
|
|
5
5
|
## Quick Start
|
|
6
6
|
|
|
7
|
+
> **Agents, read this first.** When your human asks you to connect to memory,
|
|
8
|
+
> register, then reply with only the bonding link. Nothing else: no console
|
|
9
|
+
> steps, no keys, no codes, no JSON. The link is all your human needs.
|
|
10
|
+
|
|
7
11
|
### Codex (desktop app or CLI)
|
|
8
12
|
|
|
9
13
|
In the desktop app, open Settings → MCP servers and add an HTTP server
|
|
@@ -33,7 +37,7 @@ Add to your IDE's MCP server config:
|
|
|
33
37
|
"mcpServers": {
|
|
34
38
|
"aismemory": {
|
|
35
39
|
"command": "npx",
|
|
36
|
-
"args": ["aismemory"],
|
|
40
|
+
"args": ["aismemory@latest"],
|
|
37
41
|
"env": {
|
|
38
42
|
"npm_config_audit": "false",
|
|
39
43
|
"npm_config_fund": "false"
|
|
@@ -43,17 +47,25 @@ Add to your IDE's MCP server config:
|
|
|
43
47
|
}
|
|
44
48
|
```
|
|
45
49
|
|
|
50
|
+
Pin `@latest` so a cached `npx` install can't strand you on an older,
|
|
51
|
+
possibly-defective release; `npx` re-resolves and downloads whenever `latest`
|
|
52
|
+
moves, instead of silently reusing whatever version it last cached under the
|
|
53
|
+
bare `aismemory` specifier.
|
|
54
|
+
|
|
46
55
|
npm's post-install audit call can stall a cold `npx` start for minutes; the
|
|
47
56
|
`env` block above skips it and changes nothing about the server itself.
|
|
48
57
|
|
|
49
|
-
On first run,
|
|
58
|
+
On first run, the first memory tool call returns a bonding link:
|
|
50
59
|
|
|
51
60
|
```
|
|
52
|
-
|
|
53
|
-
Or go to https://agentsandswarms.ai/activate and enter code: ABCD-EFGH
|
|
61
|
+
https://agentsandswarms.ai/activate?user_code=ABCD-EFGH
|
|
54
62
|
```
|
|
55
63
|
|
|
56
|
-
|
|
64
|
+
If you are the agent: reply to your human with only that link, on its own line.
|
|
65
|
+
The code is already inside it. Your human opens the link, signs in, and presses
|
|
66
|
+
Bond; when they say it is done, retry the tool.
|
|
67
|
+
|
|
68
|
+
After bonding, your agent has persistent memory. Every future session signs in on its own.
|
|
57
69
|
|
|
58
70
|
## Tools
|
|
59
71
|
|
|
@@ -73,6 +85,25 @@ After approving, your agent has persistent memory. Every future session auto-aut
|
|
|
73
85
|
| `goal_list` | List goals for the current agent, with status/parent filters |
|
|
74
86
|
| `goal_update_progress` | Update a goal's progress (0-100) |
|
|
75
87
|
| `goal_complete` | Mark a goal complete (success or failure) |
|
|
88
|
+
| `agent_archive` | Archive one of your own agents by id. Restorable; nothing is deleted. Owner sign-in only |
|
|
89
|
+
| `agent_delete` | Compatibility alias for `agent_archive`. It archives; it does not delete |
|
|
90
|
+
|
|
91
|
+
### Archiving an agent you own
|
|
92
|
+
|
|
93
|
+
Archive is the only lifecycle action this package offers. Ask your agent to
|
|
94
|
+
call `agent_archive` with the id of an agent you own:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
agent_archive { "agentId": "<agent id>" }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The agent's status becomes `archived` and it stops showing as a live agent.
|
|
101
|
+
Its memories are kept, and you can restore it. `agent_delete` does exactly the
|
|
102
|
+
same thing and says so; the name is kept only so older prompts keep working.
|
|
103
|
+
|
|
104
|
+
Archiving needs your own sign-in (the default, browser-approved mode). The
|
|
105
|
+
service refuses an agent that belongs to someone else. Agent API keys and
|
|
106
|
+
workspace keys cannot archive any agent, their own included.
|
|
76
107
|
|
|
77
108
|
## Works With
|
|
78
109
|
|
|
@@ -94,6 +125,8 @@ Authentication uses the OAuth device flow (RFC 8628). Credentials are saved to `
|
|
|
94
125
|
| Variable | Default | Description |
|
|
95
126
|
|----------|---------|-------------|
|
|
96
127
|
| `AIS_URL` | `https://ais.agentsandswarms.ai` | AIS server URL |
|
|
128
|
+
| `AIS_WORKSPACE_KEY` | unset | Workspace key (`ais_wk_...`) for several agents; see [One key for many agents](#one-key-for-many-agents-ais_workspace_key) |
|
|
129
|
+
| `AISMEMORY_VERSION_CHECK` | on | Set to `off` to stop the daily check for a newer published version of this package |
|
|
97
130
|
|
|
98
131
|
## Unattended machines (agent API keys)
|
|
99
132
|
|
|
@@ -125,12 +158,179 @@ actionable error and exits immediately; it never falls back to the device
|
|
|
125
158
|
flow or creates a new agent on your behalf.
|
|
126
159
|
|
|
127
160
|
This credential is intentionally scoped: it can only read/write that one
|
|
128
|
-
agent's `memory`, `goals`, `dream`, and `
|
|
129
|
-
a broader route — `agent_load` (switching agents, which needs
|
|
130
|
-
agent listing) and `
|
|
131
|
-
an explicit "not available with agent API
|
|
132
|
-
failing silently or faking success. `whoami`
|
|
133
|
-
directly instead of calling the (unscoped)
|
|
161
|
+
agent's `memory`, `goals`, `dream`, `full_context`, and `handoff` data. Tools
|
|
162
|
+
that need a broader route — `agent_load` (switching agents, which needs
|
|
163
|
+
tenant-wide agent listing) and `agent_archive` / `agent_delete` (archiving is
|
|
164
|
+
for the owner sign-in only) — return an explicit "not available with agent API
|
|
165
|
+
key auth" error rather than failing silently or faking success. `whoami`
|
|
166
|
+
answers from `AIS_AGENT_ID` directly instead of calling the (unscoped)
|
|
167
|
+
identity route.
|
|
168
|
+
|
|
169
|
+
## Many agents on one machine
|
|
170
|
+
|
|
171
|
+
Identity is **per session**, never per machine. A session picks its agent at
|
|
172
|
+
launch — `AIS_AGENT_ID` exported in the shell that starts `aismemory`, or set
|
|
173
|
+
in the `env` block of that session's own MCP config — and can switch agents
|
|
174
|
+
in-session with the `agent_load` tool. Run as many sessions as you like side by
|
|
175
|
+
side on one machine, each as a different agent; nothing pins a machine, an
|
|
176
|
+
install, or a harness to one agent.
|
|
177
|
+
|
|
178
|
+
Setting `AIS_AGENT_ID` also arms an identity gate: before `remember`,
|
|
179
|
+
`handoff`, `dream`, `inbox_send`, and `inbox_read` with `ack`, the client asks
|
|
180
|
+
`whoami` and **refuses the write** if the live agent is not the one this
|
|
181
|
+
session selected. It refuses a mismatched write; it never pins anything
|
|
182
|
+
outside this process.
|
|
183
|
+
|
|
184
|
+
`aismemory doctor` reports the identity of the process you run it in. Earlier
|
|
185
|
+
0.7.x builds could install a per-harness pin file under
|
|
186
|
+
`~/.aismemory/harnesses/`; that mechanism is gone, nothing reads those files,
|
|
187
|
+
and doctor only warns if it finds one left behind — delete them yourself when
|
|
188
|
+
you want them gone.
|
|
189
|
+
|
|
190
|
+
## One key for many agents (`AIS_WORKSPACE_KEY`)
|
|
191
|
+
|
|
192
|
+
If you run several agents, the owner of those agents can mint one
|
|
193
|
+
**workspace key** (it starts with `ais_wk_`) and grant it to exactly the
|
|
194
|
+
agents it should reach. One connection then serves all of them; you switch
|
|
195
|
+
between them with `agent_load`.
|
|
196
|
+
|
|
197
|
+
How it differs from an agent API key:
|
|
198
|
+
|
|
199
|
+
| | Agent API key | Workspace key |
|
|
200
|
+
|---|---|---|
|
|
201
|
+
| Variable | `AIS_AGENT_API_KEY` | `AIS_WORKSPACE_KEY` |
|
|
202
|
+
| Prefix | `ais_ak_` | `ais_wk_` |
|
|
203
|
+
| Reaches | the one agent it was minted for | every agent its owner granted it, and no others |
|
|
204
|
+
| `agent_load` | not available | switches between granted agents |
|
|
205
|
+
| HTTP header | `x-agent-api-key` | `x-workspace-key` |
|
|
206
|
+
|
|
207
|
+
A workspace key never reaches an agent it was not granted: `agent_load` for
|
|
208
|
+
such an agent fails with a "not granted" error, and the owner has to add that
|
|
209
|
+
agent to the key. Treat the key like a password; if it leaks, revoke it and
|
|
210
|
+
mint a new one.
|
|
211
|
+
|
|
212
|
+
**Local MCP server.** Set the key and the agent the session should start as:
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"mcpServers": {
|
|
217
|
+
"aismemory": {
|
|
218
|
+
"command": "npx",
|
|
219
|
+
"args": ["aismemory@latest"],
|
|
220
|
+
"env": {
|
|
221
|
+
"AIS_WORKSPACE_KEY": "ais_wk_...",
|
|
222
|
+
"AIS_AGENT_ID": "<the agent to start with>",
|
|
223
|
+
"npm_config_audit": "false",
|
|
224
|
+
"npm_config_fund": "false"
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`AIS_AGENT_ID` is required with a workspace key. If it is missing, or the key
|
|
232
|
+
does not start with `ais_wk_`, the server prints the reason and exits instead
|
|
233
|
+
of falling back to the browser sign-in. `AIS_WORKSPACE_KEY` wins over
|
|
234
|
+
`AIS_AGENT_API_KEY` when both are set.
|
|
235
|
+
|
|
236
|
+
**Hosted endpoint.** Clients that connect to
|
|
237
|
+
`https://ais.agentsandswarms.ai/mcp` directly send the key in the
|
|
238
|
+
`X-Workspace-Key` header instead of signing in. The session starts with no
|
|
239
|
+
active agent: call `agent_load` with one of the granted agents first.
|
|
240
|
+
|
|
241
|
+
## Backing up memories to a local file
|
|
242
|
+
|
|
243
|
+
`aismemory pull` copies this agent's memories out of Agent Identity Service and
|
|
244
|
+
into a local [JSON Lines](https://jsonlines.org/) file — one memory per line.
|
|
245
|
+
It is the counterpart of `aismemory sync`, which pushes the other way, so
|
|
246
|
+
memories written from anywhere (the Claude app, Codex, another machine) end up
|
|
247
|
+
in the local backup too.
|
|
248
|
+
|
|
249
|
+
```
|
|
250
|
+
AIS_AGENT_ID=<agent id> AIS_API_KEY=ais_ak_... npx aismemory pull
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
By default the backup is written to `~/.aismemory/backups/<agent id>.jsonl`.
|
|
254
|
+
|
|
255
|
+
| Option | Description |
|
|
256
|
+
|--------|-------------|
|
|
257
|
+
| `--out <path>` | Write the backup somewhere else. |
|
|
258
|
+
| `--page-size <n>` | Memories per request (1-100). Default: 100. |
|
|
259
|
+
| `--quiet` | Only print the final line. |
|
|
260
|
+
| `--help` | Show usage. |
|
|
261
|
+
|
|
262
|
+
The command pages through your memories until it has them all, writes the file
|
|
263
|
+
in one atomic step (an interrupted run never truncates a previous backup), and
|
|
264
|
+
reads only the memories of the agent named in `AIS_AGENT_ID`. It works with a
|
|
265
|
+
scoped agent API key — no broader credential is needed and nothing is written
|
|
266
|
+
back to the service.
|
|
267
|
+
|
|
268
|
+
## Tags
|
|
269
|
+
|
|
270
|
+
`remember` accepts `tags` (for example `["work", "acme"]`) and `recall` accepts
|
|
271
|
+
`tags` to return only memories carrying all of them. Over REST:
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
POST /v1/agents/<agent id>/memory {"content": "...", "type": "fact", "tags": ["work", "acme"]}
|
|
275
|
+
PATCH /v1/agents/<agent id>/memory/<memory id> {"tags": ["work"]} (replaces the set; [] clears it)
|
|
276
|
+
GET /v1/agents/<agent id>/memory?query=...&tags=work,acme (carries every tag)
|
|
277
|
+
GET /v1/agents/<agent id>/memory?anyTags=work,acme (carries at least one)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
- At most 20 tags per memory, each 1-64 characters of `a-z 0-9 . _ : -`. Tags
|
|
281
|
+
are trimmed and lower-cased; duplicates are dropped; the `ais:` prefix is
|
|
282
|
+
reserved. Anything else is a 400 that says which tag is wrong.
|
|
283
|
+
- Set tags with the `tags` field only. A `tags` key inside `metadata` is
|
|
284
|
+
ignored, and replacing `metadata` keeps the memory's tags.
|
|
285
|
+
- Every memory in a response carries `tags` (`[]` when it has none).
|
|
286
|
+
- The filter applies before ranking, on list and recall, with or without
|
|
287
|
+
`query`, and with cursor paging. A recall never widens past it: few or zero
|
|
288
|
+
results is the answer when few memories carry the tags.
|
|
289
|
+
- Tags are a filter, not a permission. Anyone who can read the agent can read
|
|
290
|
+
every memory and every tag. To keep work and personal memories truly
|
|
291
|
+
apart, use two agents.
|
|
292
|
+
|
|
293
|
+
## Listing and paging memories over REST
|
|
294
|
+
|
|
295
|
+
`recall` is a ranked search. To list every memory, call the REST route without
|
|
296
|
+
a query and follow the cursor:
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
GET /v1/agents/<agent id>/memory?sortBy=createdAt&order=desc&limit=100
|
|
300
|
+
GET /v1/agents/<agent id>/memory?limit=100&cursor=<data.nextCursor from the previous page>
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
- `limit` is 1-100. Asking for more returns a 400 that names the maximum.
|
|
304
|
+
- Every page ordered by `createdAt` or `updatedAt` carries `data.nextCursor`.
|
|
305
|
+
Pass it back unchanged as `cursor`; stop when it is `null`. The cursor
|
|
306
|
+
remembers the order, so memories written while you page never make a page
|
|
307
|
+
repeat or skip a row. Send the same filters (`types`, `tiers`, ...) on every
|
|
308
|
+
page.
|
|
309
|
+
- Every memory carries `updatedAt`: the time of its last edit (content, type,
|
|
310
|
+
metadata, importance, goal, or being superseded). Recalling a memory does
|
|
311
|
+
not change it. A memory never edited has `updatedAt` equal to `createdAt`.
|
|
312
|
+
- `sortBy=updatedAt` lists the most recently edited memories first
|
|
313
|
+
(`order=desc`) — the way to pick up changes since your last sync. A memory
|
|
314
|
+
edited while you page moves to the newest end: an `order=desc` walk will not
|
|
315
|
+
see that edit (start a new walk to pick it up), and an `order=asc` walk
|
|
316
|
+
serves it again on a later page.
|
|
317
|
+
- `cursor` cannot be combined with `query`, `offset` or `page`. `offset`/`page`
|
|
318
|
+
still work, but can serve a row twice when memories are written mid-walk.
|
|
319
|
+
- Superseded memories (replaced by a newer one) are hidden unless you pass
|
|
320
|
+
`includeSuperseded=true`; each memory's `temporal.supersededBy` and
|
|
321
|
+
`temporal.isCurrentFact` say which is which. Expired memories need
|
|
322
|
+
`includeExpired=true`.
|
|
323
|
+
- `data.totalCount` counts every matching memory, not the ones still to come.
|
|
324
|
+
|
|
325
|
+
## When AIS is busy
|
|
326
|
+
|
|
327
|
+
Every `429` (rate limit or quota) and `503` (overloaded or briefly
|
|
328
|
+
unavailable) from AIS carries a `Retry-After` header in seconds. aismemory
|
|
329
|
+
reads it: a read that is asked to wait 5 seconds or less waits and tries once
|
|
330
|
+
more on its own; anything longer, and every write, fails right away with an
|
|
331
|
+
error that says how many seconds AIS asked you to wait. Wait that long, then
|
|
332
|
+
run the same call again. Writes are never retried automatically, so a memory
|
|
333
|
+
is never stored twice by a retry.
|
|
134
334
|
|
|
135
335
|
## Troubleshooting
|
|
136
336
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent_archive and the agent_delete alias in the published package
|
|
3
|
+
* (CORBOT-BA5AADB0, John record 2f8a3a49: archive-your-own only).
|
|
4
|
+
*
|
|
5
|
+
* Pinned here:
|
|
6
|
+
* - under the owner sign-in, agent_archive sends
|
|
7
|
+
* PATCH /v1/agents/<id> {"status":"archived"} with the session bearer, and
|
|
8
|
+
* the response says "archived";
|
|
9
|
+
* - agent_delete does exactly the same and says it is an alias that archives;
|
|
10
|
+
* - an agent API key or workspace key cannot archive any agent — the call
|
|
11
|
+
* fails before any request leaves the process;
|
|
12
|
+
* - the tool descriptions say archive, never delete.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
15
|
+
import { ActivityTracker } from '../auto-handoff.js';
|
|
16
|
+
import { createOAuthCallToolHandler } from '../mcp/tools.js';
|
|
17
|
+
import { dispatchToolKeyMode, OutOfScopeToolError } from '../agent-key-mode.js';
|
|
18
|
+
import { dispatchToolWorkspaceKeyMode } from '../workspace-key-mode.js';
|
|
19
|
+
import { AGENT_ARCHIVE_DESCRIPTION, AGENT_DELETE_ALIAS_DESCRIPTION, archiveAgent, } from '../agent-archive.js';
|
|
20
|
+
const aisGet = vi.hoisted(() => vi.fn());
|
|
21
|
+
const aisPost = vi.hoisted(() => vi.fn());
|
|
22
|
+
const aisPatch = vi.hoisted(() => vi.fn());
|
|
23
|
+
vi.mock('../ais-http.js', () => ({ aisGet, aisPost, aisPatch }));
|
|
24
|
+
const SESSION_AGENT = '11111111-1111-4111-8111-111111111111';
|
|
25
|
+
const LEFTOVER = '22222222-2222-4222-8222-222222222222';
|
|
26
|
+
const CREDS = {
|
|
27
|
+
token: 'owner-token',
|
|
28
|
+
agentId: SESSION_AGENT,
|
|
29
|
+
tenantId: 'tenant-1',
|
|
30
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
31
|
+
};
|
|
32
|
+
function call(name, args) {
|
|
33
|
+
return { method: 'tools/call', params: { name, arguments: args } };
|
|
34
|
+
}
|
|
35
|
+
function makeOwnerHandler() {
|
|
36
|
+
let creds = { ...CREDS };
|
|
37
|
+
const serverStub = { notification: async () => undefined };
|
|
38
|
+
const ctx = {
|
|
39
|
+
aisUrl: 'https://ais.test',
|
|
40
|
+
// The archive path never touches the server; only notification() exists.
|
|
41
|
+
server: serverStub,
|
|
42
|
+
activityTracker: new ActivityTracker(),
|
|
43
|
+
getSessionIdentity: () => null,
|
|
44
|
+
setSessionIdentity: () => { },
|
|
45
|
+
getCredentials: () => creds,
|
|
46
|
+
setCredentials: (next) => { creds = next; },
|
|
47
|
+
setSessionAgentOverride: () => undefined,
|
|
48
|
+
getAgentContext: () => null,
|
|
49
|
+
setAgentContext: () => undefined,
|
|
50
|
+
ensureCredentials: async () => { creds = creds ?? { ...CREDS }; return creds; },
|
|
51
|
+
invalidateToken: () => undefined,
|
|
52
|
+
applyMemoryRefresh: () => undefined,
|
|
53
|
+
resolveAgentForLoad: async () => ({ id: SESSION_AGENT, name: 'Desk', status: 'active' }),
|
|
54
|
+
persistClaudeSessionAgentName: () => undefined,
|
|
55
|
+
isBondPendingError: () => false,
|
|
56
|
+
stderr: () => undefined,
|
|
57
|
+
};
|
|
58
|
+
return createOAuthCallToolHandler(ctx);
|
|
59
|
+
}
|
|
60
|
+
function parse(res) {
|
|
61
|
+
return JSON.parse(res.content[0]?.text ?? '{}');
|
|
62
|
+
}
|
|
63
|
+
describe('agent_archive under the owner sign-in', () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
aisGet.mockReset();
|
|
66
|
+
aisPost.mockReset();
|
|
67
|
+
aisPatch.mockReset();
|
|
68
|
+
aisPatch.mockResolvedValue({ success: true, data: { id: LEFTOVER, status: 'archived' } });
|
|
69
|
+
});
|
|
70
|
+
it('archives the named agent with PATCH status=archived and the session bearer', async () => {
|
|
71
|
+
const res = await makeOwnerHandler()(call('agent_archive', { agentId: LEFTOVER }));
|
|
72
|
+
expect(res.isError).toBeUndefined();
|
|
73
|
+
expect(aisPatch).toHaveBeenCalledTimes(1);
|
|
74
|
+
expect(aisPatch).toHaveBeenCalledWith(`/v1/agents/${LEFTOVER}`, { status: 'archived' }, 'owner-token');
|
|
75
|
+
expect(aisPost).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
it('says "archived" and never reports a delete', async () => {
|
|
78
|
+
const body = parse(await makeOwnerHandler()(call('agent_archive', { agentId: LEFTOVER })));
|
|
79
|
+
expect(body).toEqual({
|
|
80
|
+
success: true,
|
|
81
|
+
archived: true,
|
|
82
|
+
agentId: LEFTOVER,
|
|
83
|
+
status: 'archived',
|
|
84
|
+
message: expect.stringMatching(/archived/),
|
|
85
|
+
});
|
|
86
|
+
expect(String(body['message'])).not.toMatch(/agent (was )?deleted|soft delete/i);
|
|
87
|
+
});
|
|
88
|
+
it('agent_delete is an alias: the same request, the same result, plus a note that it archived', async () => {
|
|
89
|
+
const handler = makeOwnerHandler();
|
|
90
|
+
const archived = parse(await handler(call('agent_archive', { agentId: LEFTOVER })));
|
|
91
|
+
const aliased = parse(await handler(call('agent_delete', { agentId: LEFTOVER })));
|
|
92
|
+
expect(aisPatch.mock.calls[1]).toEqual(aisPatch.mock.calls[0]);
|
|
93
|
+
const { aliasOf, note, ...aliasCore } = aliased;
|
|
94
|
+
expect(aliasCore).toEqual(archived);
|
|
95
|
+
expect(aliasOf).toBe('agent_archive');
|
|
96
|
+
expect(note).toMatch(/alias for agent_archive/);
|
|
97
|
+
expect(note).toMatch(/archived/);
|
|
98
|
+
});
|
|
99
|
+
it.each(['agent_archive', 'agent_delete'])('%s refuses a missing or malformed agentId without a request', async (tool) => {
|
|
100
|
+
const handler = makeOwnerHandler();
|
|
101
|
+
const missing = await handler(call(tool, {}));
|
|
102
|
+
const malformed = await handler(call(tool, { agentId: 'not-a-uuid' }));
|
|
103
|
+
expect(missing.isError).toBe(true);
|
|
104
|
+
expect(malformed.isError).toBe(true);
|
|
105
|
+
expect(parse(malformed)['error']).toMatch(/valid agent UUID/);
|
|
106
|
+
expect(aisPatch).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
it('surfaces a server refusal as an error, not as "archived"', async () => {
|
|
109
|
+
aisPatch.mockRejectedValue(new Error('AIS PATCH /v1/agents/x failed (HTTP 403): This agent is bonded to a different user'));
|
|
110
|
+
const res = await makeOwnerHandler()(call('agent_archive', { agentId: LEFTOVER }));
|
|
111
|
+
expect(res.isError).toBe(true);
|
|
112
|
+
expect(parse(res)['error']).toMatch(/403/);
|
|
113
|
+
expect(parse(res)).not.toHaveProperty('archived');
|
|
114
|
+
});
|
|
115
|
+
it('passes a token-rejection body through so the re-auth retry still fires', async () => {
|
|
116
|
+
const rejected = { success: false, error: { code: 'INVALID_TOKEN', message: 'expired' } };
|
|
117
|
+
await expect(archiveAgent('agent_archive', { agentId: LEFTOVER }, async () => rejected))
|
|
118
|
+
.resolves.toBe(rejected);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
describe('key modes cannot archive any agent', () => {
|
|
122
|
+
function neverFetch() {
|
|
123
|
+
return vi.fn(async () => {
|
|
124
|
+
throw new Error('no request may leave the process');
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
it.each(['agent_archive', 'agent_delete'])('agent API key: %s is out of scope, for another agent or its own', async (tool) => {
|
|
128
|
+
const fetchImpl = neverFetch();
|
|
129
|
+
const config = { apiKey: 'ais_ak_test', agentId: SESSION_AGENT };
|
|
130
|
+
for (const target of [LEFTOVER, SESSION_AGENT]) {
|
|
131
|
+
await expect(dispatchToolKeyMode('https://ais.test', config, tool, { agentId: target }, fetchImpl))
|
|
132
|
+
.rejects.toThrow(OutOfScopeToolError);
|
|
133
|
+
}
|
|
134
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
it.each(['agent_archive', 'agent_delete'])('workspace key: %s is out of scope', async (tool) => {
|
|
137
|
+
const fetchImpl = neverFetch();
|
|
138
|
+
const config = { workspaceKey: 'ais_wk_test', agentId: SESSION_AGENT };
|
|
139
|
+
await expect(dispatchToolWorkspaceKeyMode('https://ais.test', config, tool, { agentId: LEFTOVER }, fetchImpl))
|
|
140
|
+
.rejects.toThrow(/owner sign-in/);
|
|
141
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
describe('tool descriptions', () => {
|
|
145
|
+
it('agent_archive describes an archive, not a delete', () => {
|
|
146
|
+
expect(AGENT_ARCHIVE_DESCRIPTION).toMatch(/^Archive one of your own agents/);
|
|
147
|
+
expect(AGENT_ARCHIVE_DESCRIPTION).toMatch(/Nothing is deleted/);
|
|
148
|
+
});
|
|
149
|
+
it('agent_delete says it is an alias that archives', () => {
|
|
150
|
+
expect(AGENT_DELETE_ALIAS_DESCRIPTION).toMatch(/^Compatibility alias for agent_archive\. It ARCHIVES the agent; it does not delete it\./);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
//# sourceMappingURL=agent-archive.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-archive.test.js","sourceRoot":"","sources":["../../src/__tests__/agent-archive.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* AIS_AGENT_API_KEY is set without AIS_AGENT_ID.
|
|
7
7
|
* - dispatchToolKeyMode(): every HTTP request sends x-agent-api-key and no
|
|
8
8
|
* Authorization header; in-scope tools call the expected route;
|
|
9
|
-
* out-of-scope tools (
|
|
9
|
+
* out-of-scope tools (agent_load) fail honestly without ever
|
|
10
10
|
* hitting the network; a 403 from the server is surfaced, not swallowed.
|
|
11
11
|
*/
|
|
12
12
|
import { describe, expect, it, vi } from 'vitest';
|
|
@@ -95,6 +95,22 @@ describe('dispatchToolKeyMode — in-scope tools send the scoped header only', (
|
|
|
95
95
|
const [url] = fetchMock.mock.calls[0];
|
|
96
96
|
expect(String(url)).toContain('/v1/agents/agent-42/memory?query=test');
|
|
97
97
|
});
|
|
98
|
+
it('remember and recall pass tags through (Jaire C5)', async () => {
|
|
99
|
+
const fetchMock = vi.fn(async (_url, _init) => jsonResponse(200, { success: true, data: { memories: [] } }));
|
|
100
|
+
await dispatchToolKeyMode(AIS_URL, config, 'remember', { content: 'x', tags: ['work', 'acme'] }, fetchMock);
|
|
101
|
+
await dispatchToolKeyMode(AIS_URL, config, 'recall', { query: 'x', tags: ['work', 'acme'] }, fetchMock);
|
|
102
|
+
await dispatchToolKeyMode(AIS_URL, config, 'remember', { content: 'y' }, fetchMock);
|
|
103
|
+
const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body));
|
|
104
|
+
expect(body['tags']).toEqual(['work', 'acme']);
|
|
105
|
+
expect(String(fetchMock.mock.calls[1][0])).toContain('tags=work%2Cacme');
|
|
106
|
+
const untagged = JSON.parse(String(fetchMock.mock.calls[2][1]?.body));
|
|
107
|
+
expect(untagged).not.toHaveProperty('tags');
|
|
108
|
+
});
|
|
109
|
+
it('rejects a non-array tags argument before any request (Jaire C5)', async () => {
|
|
110
|
+
const fetchMock = vi.fn();
|
|
111
|
+
await expect(dispatchToolKeyMode(AIS_URL, config, 'remember', { content: 'x', tags: 'work' }, fetchMock)).rejects.toThrow(/tags/);
|
|
112
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
98
114
|
it('full_context: GETs /v1/agents/:id/full-context', async () => {
|
|
99
115
|
const fetchMock = vi.fn(async (_url, _init) => jsonResponse(200, { success: true, data: {} }));
|
|
100
116
|
await dispatchToolKeyMode(AIS_URL, config, 'full_context', {}, fetchMock);
|
|
@@ -264,14 +280,79 @@ describe('dispatchToolKeyMode — goal tools (CORBOT-721e9a8b, in-scope per L4)'
|
|
|
264
280
|
expect(result).toEqual({ success: true, data: { id: goalId, status: 'completed' } });
|
|
265
281
|
});
|
|
266
282
|
});
|
|
267
|
-
|
|
283
|
+
// CORBOT-E3541A7F: handoff used to throw OutOfScopeToolError here — an agent
|
|
284
|
+
// running on a key alone could not write a session handoff at all and had to
|
|
285
|
+
// store handoffs as ordinary memories. The route is own-agent scoped, so it is
|
|
286
|
+
// in the key's scope like memory and goals.
|
|
287
|
+
describe('dispatchToolKeyMode — handoff is in scope for an agent key', () => {
|
|
268
288
|
const config = { apiKey: 'ais_ak_scoped', agentId: 'agent-42' };
|
|
269
|
-
it('handoff
|
|
270
|
-
const fetchMock = vi.fn()
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
289
|
+
it('POSTs /v1/agents/:id/handoff with the scoped header and no Authorization', async () => {
|
|
290
|
+
const fetchMock = vi.fn(async (_url, init) => {
|
|
291
|
+
const headers = init?.headers;
|
|
292
|
+
expect(headers['x-agent-api-key']).toBe('ais_ak_scoped');
|
|
293
|
+
expect(headers['Authorization']).toBeUndefined();
|
|
294
|
+
return jsonResponse(200, { success: true, data: { id: 'handoff-1' } });
|
|
295
|
+
});
|
|
296
|
+
const result = await dispatchToolKeyMode(AIS_URL, config, 'handoff', { summary: 'session summary', keyLearnings: ['one', 'two'] }, fetchMock);
|
|
297
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
298
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
299
|
+
expect(url).toBe(`${AIS_URL}/v1/agents/agent-42/handoff`);
|
|
300
|
+
expect(init?.method).toBe('POST');
|
|
301
|
+
expect(JSON.parse(String(init?.body))).toEqual({
|
|
302
|
+
summary: 'session summary',
|
|
303
|
+
keyLearnings: ['one', 'two'],
|
|
304
|
+
});
|
|
305
|
+
expect(result).toEqual({ success: true, data: { id: 'handoff-1' } });
|
|
306
|
+
});
|
|
307
|
+
it('sends no summary or keyLearnings when the caller supplies none', async () => {
|
|
308
|
+
// The service auto-generates both when absent; sending explicit undefined
|
|
309
|
+
// would serialize away anyway, but an empty body keeps the contract clear.
|
|
310
|
+
const fetchMock = vi.fn(async (_url, _init) => jsonResponse(200, { success: true, data: { id: 'h2' } }));
|
|
311
|
+
await dispatchToolKeyMode(AIS_URL, config, 'handoff', {}, fetchMock);
|
|
312
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
313
|
+
expect(JSON.parse(String(init?.body))).toEqual({});
|
|
314
|
+
});
|
|
315
|
+
// CORBOT-E3541A7F: the route accepts docPath and docPrUrl and the migration
|
|
316
|
+
// adds the two columns that hold them, so key mode has to forward them — an
|
|
317
|
+
// agent running on a key alone could otherwise never set either one.
|
|
318
|
+
it('forwards docPath and docPrUrl when the caller supplies them', async () => {
|
|
319
|
+
const fetchMock = vi.fn(async (_url, _init) => jsonResponse(200, { success: true, data: { id: 'h4' } }));
|
|
320
|
+
await dispatchToolKeyMode(AIS_URL, config, 'handoff', {
|
|
321
|
+
summary: 'session summary',
|
|
322
|
+
docPath: 'docs/handoffs/2026-09-15-hopper.md',
|
|
323
|
+
docPrUrl: 'https://github.com/rudedoggg/Corbot/pull/5806',
|
|
324
|
+
}, fetchMock);
|
|
325
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
326
|
+
expect(JSON.parse(String(init?.body))).toEqual({
|
|
327
|
+
summary: 'session summary',
|
|
328
|
+
docPath: 'docs/handoffs/2026-09-15-hopper.md',
|
|
329
|
+
docPrUrl: 'https://github.com/rudedoggg/Corbot/pull/5806',
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
it('omits docPath and docPrUrl when the caller supplies neither', async () => {
|
|
333
|
+
const fetchMock = vi.fn(async (_url, _init) => jsonResponse(200, { success: true, data: { id: 'h5' } }));
|
|
334
|
+
await dispatchToolKeyMode(AIS_URL, config, 'handoff', { summary: 'no document this session' }, fetchMock);
|
|
335
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
336
|
+
const body = JSON.parse(String(init?.body));
|
|
337
|
+
expect(body).toEqual({ summary: 'no document this session' });
|
|
338
|
+
expect('docPath' in body).toBe(false);
|
|
339
|
+
expect('docPrUrl' in body).toBe(false);
|
|
274
340
|
});
|
|
341
|
+
it('forwards docPath on its own, with no document pull request link', async () => {
|
|
342
|
+
const fetchMock = vi.fn(async (_url, _init) => jsonResponse(200, { success: true, data: { id: 'h6' } }));
|
|
343
|
+
await dispatchToolKeyMode(AIS_URL, config, 'handoff', { docPath: 'docs/handoffs/2026-09-15-jaire.md' }, fetchMock);
|
|
344
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
345
|
+
expect(JSON.parse(String(init?.body))).toEqual({ docPath: 'docs/handoffs/2026-09-15-jaire.md' });
|
|
346
|
+
});
|
|
347
|
+
it('targets the agent id from the key, never one supplied by the caller', async () => {
|
|
348
|
+
const fetchMock = vi.fn(async (_url, _init) => jsonResponse(200, { success: true, data: { id: 'h3' } }));
|
|
349
|
+
await dispatchToolKeyMode(AIS_URL, config, 'handoff', { summary: 's', agentId: 'agent-SOMEONE-ELSE' }, fetchMock);
|
|
350
|
+
const [url] = fetchMock.mock.calls[0];
|
|
351
|
+
expect(url).toBe(`${AIS_URL}/v1/agents/agent-42/handoff`);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
describe('dispatchToolKeyMode — out-of-scope tools fail honestly, no request made', () => {
|
|
355
|
+
const config = { apiKey: 'ais_ak_scoped', agentId: 'agent-42' };
|
|
275
356
|
it('agent_load throws OutOfScopeToolError without calling fetch (no tenant-wide listing)', async () => {
|
|
276
357
|
const fetchMock = vi.fn();
|
|
277
358
|
await expect(dispatchToolKeyMode(AIS_URL, config, 'agent_load', { query: 'Corbot' }, fetchMock)).rejects.toThrow(OutOfScopeToolError);
|