@aithos/sdk 0.1.0-alpha.56 → 0.1.0-alpha.58
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/dist/src/agent-dispatch.d.ts +18 -0
- package/dist/src/agent-dispatch.js +178 -0
- package/dist/src/agent-loop.d.ts +94 -0
- package/dist/src/agent-loop.js +95 -0
- package/dist/src/agent-tools.d.ts +24 -0
- package/dist/src/agent-tools.js +147 -0
- package/dist/src/auth.d.ts +59 -0
- package/dist/src/auth.js +121 -0
- package/dist/src/compute.d.ts +112 -0
- package/dist/src/compute.js +175 -0
- package/dist/src/data.d.ts +14 -9
- package/dist/src/data.js +77 -41
- package/dist/src/index.d.ts +8 -3
- package/dist/src/index.js +12 -2
- package/dist/test/agent-dispatch.test.d.ts +2 -0
- package/dist/test/agent-dispatch.test.js +222 -0
- package/dist/test/agent-loop.test.d.ts +2 -0
- package/dist/test/agent-loop.test.js +117 -0
- package/dist/test/agent-tools.test.d.ts +2 -0
- package/dist/test/agent-tools.test.js +50 -0
- package/dist/test/invoke-turn-sdk.test.d.ts +2 -0
- package/dist/test/invoke-turn-sdk.test.js +177 -0
- package/dist/test/owner-data-client.test.d.ts +2 -0
- package/dist/test/owner-data-client.test.js +88 -0
- package/package.json +1 -1
package/dist/src/data.js
CHANGED
|
@@ -1,35 +1,42 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright 2026 Mathieu Colla
|
|
3
3
|
/**
|
|
4
|
-
* `sdk.data` —
|
|
4
|
+
* `sdk.data` — the Aithos data sub-protocol PDS as a plain database.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Records (contacts, prospects, messages, …) live under a subject's identity,
|
|
7
|
+
* encrypted client-side, sealed under the subject's dedicated **`#data`**
|
|
8
|
+
* sphere. A developer never chooses a sphere, never handles a key — the SDK
|
|
9
|
+
* derives everything from the session. There are exactly two ways in, both off
|
|
10
|
+
* the auth session:
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
* const
|
|
13
|
-
* const id = await contacts.insert({ name: "Jean"
|
|
14
|
-
* const
|
|
12
|
+
* // 1. As the OWNER (signed in) — your own database:
|
|
13
|
+
* const db = auth.data; // === auth.ownerDataClient()
|
|
14
|
+
* const id = await db.collection("contacts").insert({ name: "Jean" });
|
|
15
|
+
* const leads = await db.collection("contacts").list({ filter: { status: "lead" } });
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* encrypted payload (server-blind), JSON-RPC dispatch.
|
|
17
|
+
* // 2. As a DELEGATE (you imported a mandate) — the subject's database:
|
|
18
|
+
* const db = auth.delegateDataClient(); // single active mandate; or { subjectDid }
|
|
19
|
+
* await db.collection("prospects").insert({ ... }); // needs data.prospects.write
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* `auth.data` returns whichever applies to how you connected, with an identical
|
|
22
|
+
* CRUD surface. Owner-only operations (createCollection, authorizeDelegate,
|
|
23
|
+
* registerSchema) belong to the owner and throw `-32042` on the delegate path:
|
|
24
|
+
* the owner holds the CMK and decides who may access the collection (a one-time
|
|
25
|
+
* onboarding step), then the delegate does record CRUD bounded by its scope.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
27
|
+
* The low-level `createDataClient` / `createDelegateDataClient` (which take a
|
|
28
|
+
* raw seed) are NOT exported from the package — they exist only as internals of
|
|
29
|
+
* the session accessors above. This is deliberate: passing a seed by hand is
|
|
30
|
+
* exactly what let an app seal data under the wrong key.
|
|
31
|
+
*
|
|
32
|
+
* Under the hood the module handles: envelope signing per spec §11, CMK / DEK
|
|
33
|
+
* lifecycle (generate, wrap, unwrap), per-record AEAD encryption, the split
|
|
34
|
+
* between indexable metadata (server-visible) and encrypted payload
|
|
35
|
+
* (server-blind), and JSON-RPC dispatch.
|
|
36
|
+
*
|
|
37
|
+
* Vendor schemas (`aithos.x.<vendor>.<name>.v<N>`) are passed via the
|
|
38
|
+
* `{ schemas: [...] }` option on the session accessor; the SDK uses them to
|
|
39
|
+
* split records into indexable metadata and encrypted payload.
|
|
33
40
|
*
|
|
34
41
|
* Spec ref: spec/data/01..10 of the aithos-protocol repo.
|
|
35
42
|
*/
|
|
@@ -61,15 +68,20 @@ export function createDataClient(args) {
|
|
|
61
68
|
return new DataClientImpl(args);
|
|
62
69
|
}
|
|
63
70
|
/**
|
|
64
|
-
* Build a
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* {@link DataClient.authorizeDelegate}.
|
|
71
|
+
* Build a data client that operates on a subject's collections under a
|
|
72
|
+
* mandate (delegate path). It signs every request as the delegate
|
|
73
|
+
* (bare-multibase verificationMethod + the mandate attached to the
|
|
74
|
+
* envelope) and decrypts/encrypts records using the CMK the owner
|
|
75
|
+
* re-wrapped for this delegate via {@link DataClient.authorizeDelegate}.
|
|
70
76
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
77
|
+
* Record CRUD is bounded by the mandate scope: reads need
|
|
78
|
+
* `data.<col>.read`, writes need `data.<col>.write` (or `.admin` /
|
|
79
|
+
* wildcard) — enforced client-side and by the PDS. Owner-only operations
|
|
80
|
+
* (createCollection, authorizeDelegate, revokeDelegate, registerSchema)
|
|
81
|
+
* always throw `-32042`: the owner holds the CMK and controls access.
|
|
82
|
+
*
|
|
83
|
+
* @internal Prefer the session accessor `auth.data` (owner) / the delegate
|
|
84
|
+
* session over hand-constructing this with a raw seed.
|
|
73
85
|
*/
|
|
74
86
|
export function createDelegateDataClient(args) {
|
|
75
87
|
const granteePubMb = args.granteePubkeyMultibase ??
|
|
@@ -171,14 +183,38 @@ class DataClientImpl {
|
|
|
171
183
|
this.#deposit = args.deposit;
|
|
172
184
|
this.#localSchemas = new Map((args.schemas ?? []).map((s) => [s.schema, s]));
|
|
173
185
|
}
|
|
174
|
-
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
186
|
+
/** Owner-only operations: collection lifecycle (create/ensure), delegate
|
|
187
|
+
* management (authorize/revoke) and schema registration. A delegate — even
|
|
188
|
+
* with a write mandate — cannot perform these: the owner holds the CMK and
|
|
189
|
+
* controls who may access the collection. The PDS enforces the same. */
|
|
177
190
|
#assertOwner(op) {
|
|
178
191
|
if (this.#delegate) {
|
|
179
|
-
const e = new Error(`sdk.data: "${op}" is
|
|
180
|
-
`
|
|
181
|
-
`
|
|
192
|
+
const e = new Error(`sdk.data: "${op}" is owner-only. A delegate (even with a write mandate) cannot ` +
|
|
193
|
+
`create collections, authorize/revoke delegates, or register schemas — the owner ` +
|
|
194
|
+
`holds the CMK and controls access. Record CRUD (insert/get/list/update/delete) ` +
|
|
195
|
+
`is available to a delegate whose mandate carries the matching scope.`);
|
|
196
|
+
e.code = -32042;
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Record-write guard. Owner: always allowed. Delegate: allowed iff the
|
|
201
|
+
* mandate carries a write/admin scope for this collection (data.<col>.write,
|
|
202
|
+
* data.*.write, data.<col>.admin, data.*.admin). The PDS enforces the same;
|
|
203
|
+
* this is the fast, precise local error. */
|
|
204
|
+
#assertCanWrite(op, collectionName) {
|
|
205
|
+
if (!this.#delegate)
|
|
206
|
+
return; // owner can always write
|
|
207
|
+
const scopes = this.#delegate.mandate.scopes ?? [];
|
|
208
|
+
const needed = [
|
|
209
|
+
`data.${collectionName}.write`,
|
|
210
|
+
`data.*.write`,
|
|
211
|
+
`data.${collectionName}.admin`,
|
|
212
|
+
`data.*.admin`,
|
|
213
|
+
];
|
|
214
|
+
const ok = scopes.some((s) => needed.includes(s.split(".").slice(0, 3).join(".")));
|
|
215
|
+
if (!ok) {
|
|
216
|
+
const e = new Error(`sdk.data: "${op}" on "${collectionName}" requires a data.${collectionName}.write ` +
|
|
217
|
+
`(or .admin / wildcard) scope. This mandate grants: [${scopes.join(", ")}].`);
|
|
182
218
|
e.code = -32042;
|
|
183
219
|
throw e;
|
|
184
220
|
}
|
|
@@ -431,7 +467,7 @@ class DataClientImpl {
|
|
|
431
467
|
return state;
|
|
432
468
|
}
|
|
433
469
|
async _insert(state, record) {
|
|
434
|
-
this.#
|
|
470
|
+
this.#assertCanWrite("insert", state.name);
|
|
435
471
|
const cmk = this.#cmkCache.get(state.name);
|
|
436
472
|
if (!cmk)
|
|
437
473
|
throw new Error("CMK not loaded");
|
|
@@ -500,7 +536,7 @@ class DataClientImpl {
|
|
|
500
536
|
};
|
|
501
537
|
}
|
|
502
538
|
async _update(state, recordId, record) {
|
|
503
|
-
this.#
|
|
539
|
+
this.#assertCanWrite("update", state.name);
|
|
504
540
|
const cmk = this.#cmkCache.get(state.name);
|
|
505
541
|
if (!cmk)
|
|
506
542
|
throw new Error("CMK not loaded");
|
|
@@ -519,7 +555,7 @@ class DataClientImpl {
|
|
|
519
555
|
});
|
|
520
556
|
}
|
|
521
557
|
async _delete(state, recordId) {
|
|
522
|
-
this.#
|
|
558
|
+
this.#assertCanWrite("delete", state.name);
|
|
523
559
|
await this.#call("/mcp/primitives/write", "aithos.data.delete_record", {
|
|
524
560
|
collection_urn: state.urn,
|
|
525
561
|
record_id: recordId,
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.0-alpha.
|
|
1
|
+
export declare const VERSION = "0.1.0-alpha.57";
|
|
2
2
|
export { AithosSDK } from "./sdk.js";
|
|
3
3
|
export type { AithosSDKConfig } from "./types.js";
|
|
4
4
|
export { AithosSDKError } from "./types.js";
|
|
5
5
|
export { AithosRpcError } from "@aithos/protocol-client";
|
|
6
6
|
export type { AithosSdkEndpoints } from "./endpoints.js";
|
|
7
7
|
export { DEFAULT_SDK_ENDPOINTS } from "./endpoints.js";
|
|
8
|
-
export type { ComputeMessage, ImageAspectRatio, ImageModelId, InvokeBedrockArgs, InvokeBedrockResult, InvokeBedrockVisionArgs, InvokeBedrockVisionResult, InvokeImageArgs, InvokeImageImage, InvokeImageResult, InvokeSegmentationArgs, InvokeSegmentationResult, SegmentPolygon, StopReason, RunConversationArgs, RunConversationResult, ComputeWorkingSet, WorkingSetSection, ConverseToolCall, ConverseStopReason, TranscribeModelId, TranscribeProgressState, TranscribeSegment, TranscribeWord, InvokeTranscribeArgs, InvokeTranscribeResult, PrepareTranscribeArgs, PrepareTranscribeResult, StartTranscribeArgs, StartTranscribeResult, TranscribeStatusResult, TranscribeJobSummary, } from "./compute.js";
|
|
8
|
+
export type { ComputeMessage, ImageAspectRatio, ImageModelId, InvokeBedrockArgs, InvokeBedrockResult, InvokeBedrockVisionArgs, InvokeBedrockVisionResult, InvokeImageArgs, InvokeImageImage, InvokeImageResult, InvokeSegmentationArgs, InvokeSegmentationResult, SegmentPolygon, StopReason, RunConversationArgs, RunConversationResult, ComputeWorkingSet, WorkingSetSection, ConverseToolCall, ConverseStopReason, InvokeTurnArgs, InvokeTurnResult, RunConversationLocalArgs, RunConversationLocalResult, TranscribeModelId, TranscribeProgressState, TranscribeSegment, TranscribeWord, InvokeTranscribeArgs, InvokeTranscribeResult, PrepareTranscribeArgs, PrepareTranscribeResult, StartTranscribeArgs, StartTranscribeResult, TranscribeStatusResult, TranscribeJobSummary, } from "./compute.js";
|
|
9
9
|
export { ComputeNamespace } from "./compute.js";
|
|
10
|
+
export type { AgentMessage, AgentToolSpec, AgentTurnResult, ContentBlock, ToolCallTrace, LoopStopReason, DispatchOutcome, } from "./agent-loop.js";
|
|
11
|
+
export { runAgenticLoopLocal } from "./agent-loop.js";
|
|
12
|
+
export { AITHOS_AGENT_TOOLS, AITHOS_AGENT_READ_TOOLS, AITHOS_AGENT_WRITE_TOOLS, selectAgentTools, isWriteTool, } from "./agent-tools.js";
|
|
13
|
+
export type { AgentDispatchContext, DataProvider } from "./agent-dispatch.js";
|
|
14
|
+
export { dispatchAgentToolLocal } from "./agent-dispatch.js";
|
|
10
15
|
export type { LocalPendingEntry, LocalPendingStatus, TranscribeDraftMeta, TranscribeDraftRecord, } from "./transcribe-resilience.js";
|
|
11
16
|
export { LocalPendingTranscribeTracker, TranscribeDraftStore, TranscribeDraftUnavailableError, } from "./transcribe-resilience.js";
|
|
12
17
|
export type { CreditPackId, CreateTopupSessionArgs, CreateTopupSessionResult, GetBalanceArgs, GetBalanceResult, } from "./wallet.js";
|
|
@@ -27,7 +32,7 @@ export type { AudienceSet, AppCreditPackId, CreateAppTopupSessionArgs, CreateApp
|
|
|
27
32
|
export * as onboarding from "./onboarding.js";
|
|
28
33
|
export { createBrowserIdentity, browserIdentityFromStored, type BrowserIdentity, } from "@aithos/protocol-client";
|
|
29
34
|
export type { Section } from "@aithos/protocol-client";
|
|
30
|
-
export {
|
|
35
|
+
export { createAppendDataClient, type CreateAppendDataClientArgs, type DataClient, type DataCollection, type ReadonlyDataClient, type ReadonlyDataCollection, type AppendOnlyDataClient, type AppendOnlyDataCollection, type ListOpts, type AithosSchemaLite, liteFromPublishedSchema, } from "./data.js";
|
|
31
36
|
export { ensureDataSphere, rekeyLegacyCollections, addDataSphereWrap, migrateLegacyEthosToDataSphere, type MigrateOptions, type MigrateProgress, type EnsureDataSphereResult, type RekeyReport, type CollectionRekeyEntry, type CollectionRekeyStatus, type FullMigrationResult, } from "./migrate.js";
|
|
32
37
|
export { buildSignedRotatedDidDocument, rotateEthos, type RotationSeeds, type DidDocumentLike, type RotateEthosOptions, type RotateEthosResult, } from "./rotate.js";
|
|
33
38
|
export { createAssetsClient, AssetsClient, type CreateAssetsClientArgs, type AttachedContext, type AssetUploadInput, type AssetUploadResult, type AssetFetchResult, type AssetBrief, type ListAssetsOpts, type ThumbnailUploadInput, type ThumbnailUploadResult, type RecipientResolver, type RecipientSet, } from "./assets.js";
|
package/dist/src/index.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// Public types specific to the SDK (`AithosSDKConfig`, `AithosSDKError`)
|
|
18
18
|
// are exported from here. Endpoint config (`AithosSdkEndpoints`,
|
|
19
19
|
// `DEFAULT_SDK_ENDPOINTS`) likewise.
|
|
20
|
-
export const VERSION = "0.1.0-alpha.
|
|
20
|
+
export const VERSION = "0.1.0-alpha.57";
|
|
21
21
|
export { AithosSDK } from "./sdk.js";
|
|
22
22
|
export { AithosSDKError } from "./types.js";
|
|
23
23
|
// Re-export protocol-client's JSON-RPC error type so consumers can
|
|
@@ -26,6 +26,9 @@ export { AithosSDKError } from "./types.js";
|
|
|
26
26
|
export { AithosRpcError } from "@aithos/protocol-client";
|
|
27
27
|
export { DEFAULT_SDK_ENDPOINTS } from "./endpoints.js";
|
|
28
28
|
export { ComputeNamespace } from "./compute.js";
|
|
29
|
+
export { runAgenticLoopLocal } from "./agent-loop.js";
|
|
30
|
+
export { AITHOS_AGENT_TOOLS, AITHOS_AGENT_READ_TOOLS, AITHOS_AGENT_WRITE_TOOLS, selectAgentTools, isWriteTool, } from "./agent-tools.js";
|
|
31
|
+
export { dispatchAgentToolLocal } from "./agent-dispatch.js";
|
|
29
32
|
export { LocalPendingTranscribeTracker, TranscribeDraftStore, TranscribeDraftUnavailableError, } from "./transcribe-resilience.js";
|
|
30
33
|
export { WalletNamespace } from "./wallet.js";
|
|
31
34
|
export { WebNamespace, WEB_EXTRACT_SCOPE } from "./web.js";
|
|
@@ -68,7 +71,14 @@ export { createBrowserIdentity, browserIdentityFromStored, } from "@aithos/proto
|
|
|
68
71
|
// `sdk.data` namespace — Aithos data sub-protocol PDS client. Manages
|
|
69
72
|
// the lifecycle of subject-owned, encrypted, schema-validated records.
|
|
70
73
|
// See spec/data/ in the aithos-protocol repo.
|
|
71
|
-
|
|
74
|
+
// The low-level `createDataClient` (owner) and `createDelegateDataClient`
|
|
75
|
+
// (delegate) factories are intentionally NOT exported: they take a raw sphere
|
|
76
|
+
// seed, which is exactly the footgun that let apps sign data ops with the wrong
|
|
77
|
+
// key. Use the session instead — `auth.data` / `auth.ownerDataClient()` for the
|
|
78
|
+
// owner (always `#data`), `auth.delegateDataClient()` for a mandate-bearing
|
|
79
|
+
// delegate. The key/sphere is derived under the hood; the developer only ever
|
|
80
|
+
// sees a database.
|
|
81
|
+
export { createAppendDataClient,
|
|
72
82
|
// Derive an AithosSchemaLite from a published JSON Schema (vendor schemas the
|
|
73
83
|
// client didn't bundle). The SDK uses this internally to auto-resolve unknown
|
|
74
84
|
// collection schemas from the PDS; exported for apps that want it directly.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Tests for the CLIENT-SIDE local tool dispatch: read navigation, write
|
|
4
|
+
// staging + publish, and the ethos.write.* authorisation gate. The EthosClient
|
|
5
|
+
// is faked (no network) — we assert on what got staged/published and on the
|
|
6
|
+
// is_error outcomes for out-of-scope or malformed calls.
|
|
7
|
+
import { strict as assert } from "node:assert";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import { dispatchAgentToolLocal, AithosSDKError, } from "../src/index.js";
|
|
10
|
+
/**
|
|
11
|
+
* Build a fake EthosClient. `zones` maps zone→sections (a zone absent from the
|
|
12
|
+
* map is treated as unreadable and throws like an ungranted/undecryptable
|
|
13
|
+
* zone, exercising the dispatch's skip-on-error path).
|
|
14
|
+
*/
|
|
15
|
+
function fakeEthos(mode, zones) {
|
|
16
|
+
const state = { staged: [], publishes: 0 };
|
|
17
|
+
const client = {
|
|
18
|
+
mode,
|
|
19
|
+
subjectDid: "did:aithos:subject",
|
|
20
|
+
zone(name) {
|
|
21
|
+
return {
|
|
22
|
+
async sections() {
|
|
23
|
+
const s = zones[name];
|
|
24
|
+
if (!s) {
|
|
25
|
+
throw new AithosSDKError("ethos_zone_unreadable", `cannot read ${name}`);
|
|
26
|
+
}
|
|
27
|
+
return s;
|
|
28
|
+
},
|
|
29
|
+
addSection(input) {
|
|
30
|
+
state.staged.push({ op: "add", zone: name, arg: input });
|
|
31
|
+
},
|
|
32
|
+
updateSection(id, patch) {
|
|
33
|
+
state.staged.push({ op: "update", zone: name, arg: { id, patch } });
|
|
34
|
+
},
|
|
35
|
+
deleteSection(id) {
|
|
36
|
+
state.staged.push({ op: "delete", zone: name, arg: { id } });
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
async publish() {
|
|
41
|
+
state.publishes++;
|
|
42
|
+
return {
|
|
43
|
+
editionHeight: 2,
|
|
44
|
+
manifestHash: "",
|
|
45
|
+
subjectDid: "did:aithos:subject",
|
|
46
|
+
zonesPublished: [],
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return { client, state };
|
|
51
|
+
}
|
|
52
|
+
function ownerCtx(zones) {
|
|
53
|
+
const { client, state } = fakeEthos("owner", zones);
|
|
54
|
+
return { ctx: { ethos: client, delegateScopes: [] }, state };
|
|
55
|
+
}
|
|
56
|
+
function delegateCtx(zones, scopes) {
|
|
57
|
+
const { client, state } = fakeEthos("delegate", zones);
|
|
58
|
+
return { ctx: { ethos: client, delegateScopes: scopes }, state };
|
|
59
|
+
}
|
|
60
|
+
const parse = (o) => JSON.parse(o.payload);
|
|
61
|
+
describe("dispatch — reads", () => {
|
|
62
|
+
it("ethos_list_sections aggregates readable zones (skips unreadable)", async () => {
|
|
63
|
+
const { ctx } = ownerCtx({
|
|
64
|
+
public: [{ id: "p1", title: "Bio", body: "..." }],
|
|
65
|
+
// circle absent → unreadable → skipped
|
|
66
|
+
self: [{ id: "s1", title: "Secret", body: "..." }],
|
|
67
|
+
});
|
|
68
|
+
const out = await dispatchAgentToolLocal(ctx, "ethos_list_sections", {});
|
|
69
|
+
assert.equal(out.isError, false);
|
|
70
|
+
const { sections } = parse(out);
|
|
71
|
+
assert.deepEqual(sections.sort((a, b) => a.id.localeCompare(b.id)), [
|
|
72
|
+
{ zone: "public", id: "p1", title: "Bio" },
|
|
73
|
+
{ zone: "self", id: "s1", title: "Secret" },
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
it("ethos_read_section returns body for a readable section", async () => {
|
|
77
|
+
const { ctx } = ownerCtx({ public: [{ id: "p1", title: "Bio", body: "hello" }] });
|
|
78
|
+
const out = await dispatchAgentToolLocal(ctx, "ethos_read_section", { section_id: "p1" });
|
|
79
|
+
assert.equal(out.isError, false);
|
|
80
|
+
assert.deepEqual(parse(out), { zone: "public", title: "Bio", body: "hello" });
|
|
81
|
+
});
|
|
82
|
+
it("ethos_read_section is_error for unknown id", async () => {
|
|
83
|
+
const { ctx } = ownerCtx({ public: [] });
|
|
84
|
+
const out = await dispatchAgentToolLocal(ctx, "ethos_read_section", { section_id: "nope" });
|
|
85
|
+
assert.equal(out.isError, true);
|
|
86
|
+
});
|
|
87
|
+
it("data_query is_error without a data provider", async () => {
|
|
88
|
+
const { ctx } = ownerCtx({ public: [] });
|
|
89
|
+
const out = await dispatchAgentToolLocal(ctx, "data_query", { collection: "contacts" });
|
|
90
|
+
assert.equal(out.isError, true);
|
|
91
|
+
});
|
|
92
|
+
it("data_query uses the provider when present (limit clamped)", async () => {
|
|
93
|
+
const { client } = fakeEthos("owner", { public: [] });
|
|
94
|
+
let seenLimit = -1;
|
|
95
|
+
const ctx = {
|
|
96
|
+
ethos: client,
|
|
97
|
+
delegateScopes: [],
|
|
98
|
+
dataProvider: async (_c, limit) => {
|
|
99
|
+
seenLimit = limit;
|
|
100
|
+
return [{ a: 1 }];
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const out = await dispatchAgentToolLocal(ctx, "data_query", { collection: "c", limit: 999 });
|
|
104
|
+
assert.equal(out.isError, false);
|
|
105
|
+
assert.equal(seenLimit, 100); // clamped to max
|
|
106
|
+
assert.deepEqual(parse(out).records, [{ a: 1 }]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe("dispatch — writes (owner)", () => {
|
|
110
|
+
it("ethos_add_section stages + publishes", async () => {
|
|
111
|
+
const { ctx, state } = ownerCtx({ public: [] });
|
|
112
|
+
const out = await dispatchAgentToolLocal(ctx, "ethos_add_section", {
|
|
113
|
+
zone: "public",
|
|
114
|
+
title: "New",
|
|
115
|
+
body: "content",
|
|
116
|
+
});
|
|
117
|
+
assert.equal(out.isError, false);
|
|
118
|
+
assert.equal(parse(out).published, true);
|
|
119
|
+
assert.equal(state.publishes, 1);
|
|
120
|
+
assert.deepEqual(state.staged, [
|
|
121
|
+
{ op: "add", zone: "public", arg: { title: "New", body: "content" } },
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
it("ethos_update_section locates the zone then publishes", async () => {
|
|
125
|
+
const { ctx, state } = ownerCtx({
|
|
126
|
+
circle: [{ id: "c1", title: "Old", body: "x" }],
|
|
127
|
+
});
|
|
128
|
+
const out = await dispatchAgentToolLocal(ctx, "ethos_update_section", {
|
|
129
|
+
section_id: "c1",
|
|
130
|
+
body: "new body",
|
|
131
|
+
});
|
|
132
|
+
assert.equal(out.isError, false);
|
|
133
|
+
assert.equal(parse(out).zone, "circle");
|
|
134
|
+
assert.equal(state.publishes, 1);
|
|
135
|
+
assert.deepEqual(state.staged[0], {
|
|
136
|
+
op: "update",
|
|
137
|
+
zone: "circle",
|
|
138
|
+
arg: { id: "c1", patch: { body: "new body" } },
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
it("ethos_delete_section locates the zone then publishes", async () => {
|
|
142
|
+
const { ctx, state } = ownerCtx({ self: [{ id: "s9", title: "T", body: "B" }] });
|
|
143
|
+
const out = await dispatchAgentToolLocal(ctx, "ethos_delete_section", { section_id: "s9" });
|
|
144
|
+
assert.equal(out.isError, false);
|
|
145
|
+
assert.equal(parse(out).zone, "self");
|
|
146
|
+
assert.equal(state.publishes, 1);
|
|
147
|
+
});
|
|
148
|
+
it("invalid zone / missing fields → is_error, nothing published", async () => {
|
|
149
|
+
const { ctx, state } = ownerCtx({ public: [] });
|
|
150
|
+
const bad = await dispatchAgentToolLocal(ctx, "ethos_add_section", {
|
|
151
|
+
zone: "nope",
|
|
152
|
+
title: "x",
|
|
153
|
+
body: "y",
|
|
154
|
+
});
|
|
155
|
+
assert.equal(bad.isError, true);
|
|
156
|
+
const noTitle = await dispatchAgentToolLocal(ctx, "ethos_add_section", {
|
|
157
|
+
zone: "public",
|
|
158
|
+
body: "y",
|
|
159
|
+
});
|
|
160
|
+
assert.equal(noTitle.isError, true);
|
|
161
|
+
assert.equal(state.publishes, 0);
|
|
162
|
+
});
|
|
163
|
+
it("update with nothing to change → is_error", async () => {
|
|
164
|
+
const { ctx } = ownerCtx({ public: [{ id: "p1", title: "T", body: "B" }] });
|
|
165
|
+
const out = await dispatchAgentToolLocal(ctx, "ethos_update_section", { section_id: "p1" });
|
|
166
|
+
assert.equal(out.isError, true);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe("dispatch — write authorisation gate (delegate)", () => {
|
|
170
|
+
it("publishes when the mandate grants ethos.write.<zone>", async () => {
|
|
171
|
+
const { ctx, state } = delegateCtx({ public: [] }, ["ethos.write.public"]);
|
|
172
|
+
const out = await dispatchAgentToolLocal(ctx, "ethos_add_section", {
|
|
173
|
+
zone: "public",
|
|
174
|
+
title: "T",
|
|
175
|
+
body: "B",
|
|
176
|
+
});
|
|
177
|
+
assert.equal(out.isError, false);
|
|
178
|
+
assert.equal(state.publishes, 1);
|
|
179
|
+
});
|
|
180
|
+
it("refuses (is_error, no publish) when the write scope is missing", async () => {
|
|
181
|
+
const { ctx, state } = delegateCtx({ self: [] }, ["ethos.write.public"]);
|
|
182
|
+
const out = await dispatchAgentToolLocal(ctx, "ethos_add_section", {
|
|
183
|
+
zone: "self",
|
|
184
|
+
title: "T",
|
|
185
|
+
body: "B",
|
|
186
|
+
});
|
|
187
|
+
assert.equal(out.isError, true);
|
|
188
|
+
assert.match(parse(out).error, /ethos\.write\.self/);
|
|
189
|
+
assert.equal(state.publishes, 0);
|
|
190
|
+
assert.equal(state.staged.length, 0);
|
|
191
|
+
});
|
|
192
|
+
it("update refuses when write scope for the located zone is missing", async () => {
|
|
193
|
+
const { ctx, state } = delegateCtx({ circle: [{ id: "c1", title: "T", body: "B" }] }, ["ethos.write.public"]);
|
|
194
|
+
const out = await dispatchAgentToolLocal(ctx, "ethos_update_section", {
|
|
195
|
+
section_id: "c1",
|
|
196
|
+
body: "new",
|
|
197
|
+
});
|
|
198
|
+
assert.equal(out.isError, true);
|
|
199
|
+
assert.equal(state.publishes, 0);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
describe("dispatch — anonymous cannot write", () => {
|
|
203
|
+
it("ethos_add_section is_error for anonymous", async () => {
|
|
204
|
+
const { client, state } = fakeEthos("anonymous", { public: [] });
|
|
205
|
+
const ctx = { ethos: client, delegateScopes: [] };
|
|
206
|
+
const out = await dispatchAgentToolLocal(ctx, "ethos_add_section", {
|
|
207
|
+
zone: "public",
|
|
208
|
+
title: "T",
|
|
209
|
+
body: "B",
|
|
210
|
+
});
|
|
211
|
+
assert.equal(out.isError, true);
|
|
212
|
+
assert.equal(state.publishes, 0);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
describe("dispatch — unknown tool", () => {
|
|
216
|
+
it("returns is_error for an unknown tool name", async () => {
|
|
217
|
+
const { ctx } = ownerCtx({ public: [] });
|
|
218
|
+
const out = await dispatchAgentToolLocal(ctx, "bogus_tool", {});
|
|
219
|
+
assert.equal(out.isError, true);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
//# sourceMappingURL=agent-dispatch.test.js.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Tests for the pure CLIENT-SIDE agentic loop (ported from the proxy's
|
|
4
|
+
// converse-loop tests, adapted to the async local dispatch + per-turn billing
|
|
5
|
+
// accumulation).
|
|
6
|
+
import { strict as assert } from "node:assert";
|
|
7
|
+
import { describe, it } from "node:test";
|
|
8
|
+
import { runAgenticLoopLocal, } from "../src/index.js";
|
|
9
|
+
function turn(content, stopReason, i = 100, o = 50, credits = 10, balance = 1000) {
|
|
10
|
+
return {
|
|
11
|
+
content,
|
|
12
|
+
stopReason,
|
|
13
|
+
usage: { inputTokens: i, outputTokens: o },
|
|
14
|
+
creditsCharged: credits,
|
|
15
|
+
walletBalance: balance,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const okDispatch = async () => ({
|
|
19
|
+
payload: '{"ok":true}',
|
|
20
|
+
isError: false,
|
|
21
|
+
});
|
|
22
|
+
describe("runAgenticLoopLocal", () => {
|
|
23
|
+
it("returns immediately on a single end_turn (no tools)", async () => {
|
|
24
|
+
let calls = 0;
|
|
25
|
+
const r = await runAgenticLoopLocal({
|
|
26
|
+
messages: [{ role: "user", content: "salut" }],
|
|
27
|
+
maxIterations: 6,
|
|
28
|
+
invokeTurn: async () => {
|
|
29
|
+
calls++;
|
|
30
|
+
return turn([{ type: "text", text: "Réponse directe." }], "end_turn");
|
|
31
|
+
},
|
|
32
|
+
dispatch: okDispatch,
|
|
33
|
+
});
|
|
34
|
+
assert.equal(r.iterations, 1);
|
|
35
|
+
assert.equal(r.stopReason, "end_turn");
|
|
36
|
+
assert.equal(r.finalContent, "Réponse directe.");
|
|
37
|
+
assert.deepEqual(r.toolCalls, []);
|
|
38
|
+
assert.equal(calls, 1);
|
|
39
|
+
assert.equal(r.creditsCharged, 10);
|
|
40
|
+
assert.equal(r.walletBalance, 1000);
|
|
41
|
+
});
|
|
42
|
+
it("runs a tool turn then concludes, summing usage + per-turn credits", async () => {
|
|
43
|
+
const seq = [
|
|
44
|
+
turn([
|
|
45
|
+
{ type: "text", text: "je lis" },
|
|
46
|
+
{ type: "tool_use", id: "tu1", name: "ethos_read_section", input: { section_id: "s1" } },
|
|
47
|
+
], "tool_use", 100, 20, 7, 993),
|
|
48
|
+
turn([{ type: "text", text: "Fini." }], "end_turn", 50, 30, 5, 988),
|
|
49
|
+
];
|
|
50
|
+
let n = 0;
|
|
51
|
+
const dispatched = [];
|
|
52
|
+
const r = await runAgenticLoopLocal({
|
|
53
|
+
messages: [{ role: "user", content: "lis s1" }],
|
|
54
|
+
maxIterations: 6,
|
|
55
|
+
invokeTurn: async (messages) => {
|
|
56
|
+
// After the first turn, the running list must carry the assistant
|
|
57
|
+
// tool_use turn + the user tool_result turn.
|
|
58
|
+
if (n === 1) {
|
|
59
|
+
assert.equal(messages.length, 3);
|
|
60
|
+
assert.equal(messages[1].role, "assistant");
|
|
61
|
+
assert.equal(messages[2].role, "user");
|
|
62
|
+
}
|
|
63
|
+
return seq[n++];
|
|
64
|
+
},
|
|
65
|
+
dispatch: async (name) => {
|
|
66
|
+
dispatched.push(name);
|
|
67
|
+
return { payload: '{"body":"x"}', isError: false };
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
assert.equal(r.iterations, 2);
|
|
71
|
+
assert.equal(r.stopReason, "end_turn");
|
|
72
|
+
assert.equal(r.finalContent, "Fini.");
|
|
73
|
+
assert.deepEqual(dispatched, ["ethos_read_section"]);
|
|
74
|
+
assert.deepEqual(r.toolCalls, [{ name: "ethos_read_section", ok: true, turn: 1 }]);
|
|
75
|
+
assert.equal(r.usage.inputTokens, 150);
|
|
76
|
+
assert.equal(r.usage.outputTokens, 50);
|
|
77
|
+
assert.equal(r.creditsCharged, 12); // 7 + 5
|
|
78
|
+
assert.equal(r.walletBalance, 988); // last turn
|
|
79
|
+
});
|
|
80
|
+
it("a tool error becomes is_error and does NOT stop the loop", async () => {
|
|
81
|
+
const seq = [
|
|
82
|
+
turn([{ type: "tool_use", id: "tu1", name: "ethos_add_section", input: {} }], "tool_use"),
|
|
83
|
+
turn([{ type: "text", text: "ok malgré erreur" }], "end_turn"),
|
|
84
|
+
];
|
|
85
|
+
let n = 0;
|
|
86
|
+
const r = await runAgenticLoopLocal({
|
|
87
|
+
messages: [{ role: "user", content: "ajoute" }],
|
|
88
|
+
maxIterations: 6,
|
|
89
|
+
invokeTurn: async () => seq[n++],
|
|
90
|
+
dispatch: async () => ({ payload: '{"error":"nope"}', isError: true }),
|
|
91
|
+
});
|
|
92
|
+
assert.equal(r.stopReason, "end_turn");
|
|
93
|
+
assert.deepEqual(r.toolCalls, [{ name: "ethos_add_section", ok: false, turn: 1 }]);
|
|
94
|
+
assert.equal(r.finalContent, "ok malgré erreur");
|
|
95
|
+
});
|
|
96
|
+
it("stops at the iteration cap when the model keeps calling tools", async () => {
|
|
97
|
+
let calls = 0;
|
|
98
|
+
const r = await runAgenticLoopLocal({
|
|
99
|
+
messages: [{ role: "user", content: "boucle" }],
|
|
100
|
+
maxIterations: 3,
|
|
101
|
+
invokeTurn: async () => {
|
|
102
|
+
calls++;
|
|
103
|
+
return turn([
|
|
104
|
+
{ type: "text", text: `t${calls}` },
|
|
105
|
+
{ type: "tool_use", id: `tu${calls}`, name: "ethos_list_sections", input: {} },
|
|
106
|
+
], "tool_use");
|
|
107
|
+
},
|
|
108
|
+
dispatch: okDispatch,
|
|
109
|
+
});
|
|
110
|
+
assert.equal(r.stopReason, "max_iterations");
|
|
111
|
+
assert.equal(r.iterations, 3);
|
|
112
|
+
assert.equal(calls, 3);
|
|
113
|
+
assert.equal(r.toolCalls.length, 3);
|
|
114
|
+
assert.equal(r.finalContent, "t3");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
//# sourceMappingURL=agent-loop.test.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
import { strict as assert } from "node:assert";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { AITHOS_AGENT_TOOLS, AITHOS_AGENT_READ_TOOLS, AITHOS_AGENT_WRITE_TOOLS, selectAgentTools, isWriteTool, } from "../src/index.js";
|
|
6
|
+
const names = (ts) => ts.map((t) => t.name).sort();
|
|
7
|
+
describe("agent tool catalogue", () => {
|
|
8
|
+
it("the full catalogue = read + write families", () => {
|
|
9
|
+
assert.equal(AITHOS_AGENT_TOOLS.length, AITHOS_AGENT_READ_TOOLS.length + AITHOS_AGENT_WRITE_TOOLS.length);
|
|
10
|
+
assert.deepEqual(names(AITHOS_AGENT_READ_TOOLS), [
|
|
11
|
+
"data_query",
|
|
12
|
+
"ethos_list_sections",
|
|
13
|
+
"ethos_read_section",
|
|
14
|
+
]);
|
|
15
|
+
assert.deepEqual(names(AITHOS_AGENT_WRITE_TOOLS), [
|
|
16
|
+
"ethos_add_section",
|
|
17
|
+
"ethos_delete_section",
|
|
18
|
+
"ethos_update_section",
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
it("every tool has a name, description, and object input_schema", () => {
|
|
22
|
+
for (const t of AITHOS_AGENT_TOOLS) {
|
|
23
|
+
assert.ok(t.name.length > 0);
|
|
24
|
+
assert.ok(t.description.length > 0);
|
|
25
|
+
assert.equal(typeof t.input_schema, "object");
|
|
26
|
+
assert.equal(t.input_schema.type, "object");
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
it("isWriteTool flags only the write family", () => {
|
|
30
|
+
assert.equal(isWriteTool("ethos_add_section"), true);
|
|
31
|
+
assert.equal(isWriteTool("ethos_update_section"), true);
|
|
32
|
+
assert.equal(isWriteTool("ethos_delete_section"), true);
|
|
33
|
+
assert.equal(isWriteTool("ethos_read_section"), false);
|
|
34
|
+
assert.equal(isWriteTool("data_query"), false);
|
|
35
|
+
assert.equal(isWriteTool("bogus"), false);
|
|
36
|
+
});
|
|
37
|
+
it("selectAgentTools: default = full catalogue", () => {
|
|
38
|
+
assert.equal(selectAgentTools().length, AITHOS_AGENT_TOOLS.length);
|
|
39
|
+
assert.equal(selectAgentTools({}).length, AITHOS_AGENT_TOOLS.length);
|
|
40
|
+
assert.equal(selectAgentTools({ tools: [] }).length, AITHOS_AGENT_TOOLS.length);
|
|
41
|
+
});
|
|
42
|
+
it("selectAgentTools: readOnly = read family only", () => {
|
|
43
|
+
assert.deepEqual(names(selectAgentTools({ readOnly: true })), names(AITHOS_AGENT_READ_TOOLS));
|
|
44
|
+
});
|
|
45
|
+
it("selectAgentTools: subset by name, unknown names ignored", () => {
|
|
46
|
+
const sel = selectAgentTools({ tools: ["ethos_add_section", "nope", "data_query"] });
|
|
47
|
+
assert.deepEqual(names(sel), ["data_query", "ethos_add_section"]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
//# sourceMappingURL=agent-tools.test.js.map
|