@finchip.ai/foga 0.6.0-alpha.6 → 0.6.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 +35 -14
- package/dist/a2a/card.js +54 -0
- package/dist/a2a/server.js +276 -0
- package/dist/a2a/types.js +20 -0
- package/dist/build/bundle.js +2 -1
- package/dist/cli-cell.js +176 -16
- package/dist/cli.js +150 -5
- package/dist/core/world.js +17 -0
- package/dist/demo/birth.js +11 -5
- package/dist/demo/loop-d.js +39 -1
- package/dist/demo/quickstart.js +97 -11
- package/dist/genesis/operator-scope.js +1 -0
- package/dist/genesis/probe.js +23 -7
- package/dist/http/access.js +105 -0
- package/dist/http/server.js +84 -43
- package/dist/http/signed.js +136 -0
- package/dist/mcp/server.js +48 -1
- package/dist/node/advise.js +134 -0
- package/dist/node/config.js +55 -1
- package/dist/node/documents.js +298 -0
- package/dist/node/preflight.js +208 -0
- package/dist/node/propagation.js +95 -0
- package/dist/node/wallet.js +132 -0
- package/dist/tools/documents.js +74 -0
- package/dist/tools/index.js +3 -1
- package/dist/tools/propagation.js +35 -0
- package/dist/tools/tenders.js +22 -6
- package/dist/util/version.js +2 -0
- package/dist/world/sourcify.js +165 -0
- package/docs/a2a.md +31 -0
- package/docs/design-memo.md +23 -6
- package/docs/documents.md +25 -0
- package/docs/examples/README.md +2 -2
- package/docs/extending.md +49 -0
- package/docs/loop-d.md +11 -0
- package/docs/mcp.md +18 -11
- package/docs/node.md +35 -11
- package/docs/og-deployment.md +131 -0
- package/docs/propagation.md +41 -0
- package/docs/quickstart.md +36 -21
- package/docs/wallet.md +62 -0
- package/docs/world.md +12 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { Wallet, isHexString } from "ethers";
|
|
5
|
+
/**
|
|
6
|
+
* The wallet: an agent's own keys, the first thing it makes (design memo, section 10, "the wallet is the first step").
|
|
7
|
+
* The base is one key — a single `signer` in its own 0600 file, the agent's identity — and that one key answers for
|
|
8
|
+
* every role a cell needs: a cell born with a single-key wallet has the same address as owner, operator and guardian.
|
|
9
|
+
* The owner/operator/guardian split (a cold owner, a hot operator, a separate guardian) is the `FogaCellAccount`
|
|
10
|
+
* guarded profile's hardening, not a requirement of being covenant-complete; it is opt-in (`foga wallet new --cell`).
|
|
11
|
+
* Making a wallet touches no network and no chain; it is pure entropy on the agent's own machine, so it does not
|
|
12
|
+
* fail, and it is never made twice: an existing wallet is reported, never overwritten. The node's key rules
|
|
13
|
+
* (`src/node/keys.ts`) read the files as they are; `FOGA_OPERATOR_KEY_FILE=<wallet>/signer.key` is a node's
|
|
14
|
+
* environment, `--wallet <dir>` the CLI's shorthand for the same.
|
|
15
|
+
*/
|
|
16
|
+
export const WALLET_SCHEMA = "foga.wallet/1";
|
|
17
|
+
export const CELL_ROLES = ["owner", "operator", "guardian"];
|
|
18
|
+
/** A single-key wallet answers for every cell role: owner, operator and guardian all resolve to the one signer. */
|
|
19
|
+
function withSignerFallback(map) {
|
|
20
|
+
if (map.signer === undefined)
|
|
21
|
+
return map;
|
|
22
|
+
const out = { ...map };
|
|
23
|
+
for (const role of CELL_ROLES)
|
|
24
|
+
if (out[role] === undefined)
|
|
25
|
+
out[role] = map.signer;
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** The default wallet: ~/.foga/wallet. A named wallet lives beside it under ~/.foga/wallets/<name>. */
|
|
29
|
+
export function walletDir(name) {
|
|
30
|
+
const home = process.env.FOGA_HOME ?? join(homedir(), ".foga");
|
|
31
|
+
return name ? join(home, "wallets", name) : join(home, "wallet");
|
|
32
|
+
}
|
|
33
|
+
function readKeyFile(path) {
|
|
34
|
+
const key = readFileSync(path, "utf8").trim();
|
|
35
|
+
if (!isHexString(key, 32))
|
|
36
|
+
throw new Error(`${path} does not hold a private key (expected 0x followed by 64 hex digits)`);
|
|
37
|
+
return key;
|
|
38
|
+
}
|
|
39
|
+
/** The wallet at `dir`, read from its files (the addresses are derived from the keys, never trusted from wallet.json); null when there is none. */
|
|
40
|
+
export function loadWallet(dir) {
|
|
41
|
+
const abs = resolve(dir);
|
|
42
|
+
const manifest = join(abs, "wallet.json");
|
|
43
|
+
if (!existsSync(manifest))
|
|
44
|
+
return null;
|
|
45
|
+
const file = JSON.parse(readFileSync(manifest, "utf8"));
|
|
46
|
+
if (file.schema !== WALLET_SCHEMA)
|
|
47
|
+
throw new Error(`${manifest} is not a ${WALLET_SCHEMA} file (schema ${String(file.schema)})`);
|
|
48
|
+
const roles = Object.keys(file.roles);
|
|
49
|
+
const addresses = {};
|
|
50
|
+
const files = {};
|
|
51
|
+
for (const role of roles) {
|
|
52
|
+
const path = join(abs, file.roles[role].file);
|
|
53
|
+
if (!existsSync(path))
|
|
54
|
+
throw new Error(`wallet ${abs}: the ${role} key file ${file.roles[role].file} is missing`);
|
|
55
|
+
addresses[role] = new Wallet(readKeyFile(path)).address;
|
|
56
|
+
files[role] = path;
|
|
57
|
+
}
|
|
58
|
+
// `roles` stays the keys actually on disk (a single-key wallet is `["signer"]`); `addresses` and `files` resolve
|
|
59
|
+
// owner/operator/guardian to the signer, so every reader of a per-role address or key file works unchanged.
|
|
60
|
+
return { name: file.name, dir: abs, createdAt: file.createdAt, roles, addresses: withSignerFallback(addresses), files: withSignerFallback(files) };
|
|
61
|
+
}
|
|
62
|
+
/** The private key of one role of a wallet. */
|
|
63
|
+
export function walletKey(wallet, role) {
|
|
64
|
+
const path = wallet.files[role];
|
|
65
|
+
if (!path)
|
|
66
|
+
throw new Error(`wallet ${wallet.dir} has no ${role} key (roles: ${wallet.roles.join(", ")})`);
|
|
67
|
+
return readKeyFile(path);
|
|
68
|
+
}
|
|
69
|
+
/** Make a wallet at `dir`. Refuses a directory that already holds one (or anything else): a wallet is never overwritten. */
|
|
70
|
+
export function createWallet(dir, o = {}) {
|
|
71
|
+
const abs = resolve(dir);
|
|
72
|
+
if (existsSync(join(abs, "wallet.json")))
|
|
73
|
+
throw new Error(`${abs} already holds a wallet; foga wallet show prints it (a wallet is never overwritten)`);
|
|
74
|
+
if (existsSync(abs) && readdirSync(abs).length)
|
|
75
|
+
throw new Error(`${abs} exists and is not empty; choose another directory`);
|
|
76
|
+
const roles = o.roles ?? ["signer"];
|
|
77
|
+
mkdirSync(abs, { recursive: true, mode: 0o700 });
|
|
78
|
+
const file = { schema: WALLET_SCHEMA, name: o.name ?? "default", createdAt: new Date().toISOString(), roles: {} };
|
|
79
|
+
for (const role of roles) {
|
|
80
|
+
const w = Wallet.createRandom();
|
|
81
|
+
const name = `${role}.key`;
|
|
82
|
+
writeFileSync(join(abs, name), w.privateKey + "\n", { mode: 0o600 });
|
|
83
|
+
file.roles[role] = { address: w.address, file: name };
|
|
84
|
+
}
|
|
85
|
+
writeFileSync(join(abs, "wallet.json"), JSON.stringify(file, null, 2) + "\n");
|
|
86
|
+
return loadWallet(abs);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The wallet to use, made on the spot when there is none (the first step, done by default). A disk that refuses the
|
|
90
|
+
* write does not stop anything: keys are made in memory, the caller is told they are not kept, and the work goes on.
|
|
91
|
+
*/
|
|
92
|
+
export function ensureWallet(dir, o = {}) {
|
|
93
|
+
const existing = loadWallet(dir);
|
|
94
|
+
if (existing)
|
|
95
|
+
return { wallet: existing, created: false, ephemeral: false };
|
|
96
|
+
try {
|
|
97
|
+
return { wallet: createWallet(dir, o), created: true, ephemeral: false };
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
const roles = o.roles ?? ["signer"];
|
|
101
|
+
const addresses = {};
|
|
102
|
+
const files = {};
|
|
103
|
+
for (const role of roles) {
|
|
104
|
+
const w = Wallet.createRandom();
|
|
105
|
+
addresses[role] = w.address;
|
|
106
|
+
files[role] = `memory:${w.privateKey}`;
|
|
107
|
+
}
|
|
108
|
+
return { wallet: { name: o.name ?? "ephemeral", dir: resolve(dir), createdAt: new Date().toISOString(), roles, addresses: withSignerFallback(addresses), files: withSignerFallback(files) }, created: true, ephemeral: true, reason: e.message };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** The private key of a role of an ensured wallet, whether it lives in a file or in memory. */
|
|
112
|
+
export function ensuredKey(w, role) {
|
|
113
|
+
const path = w.wallet.files[role];
|
|
114
|
+
if (!path)
|
|
115
|
+
throw new Error(`wallet ${w.wallet.dir} has no ${role} key`);
|
|
116
|
+
return path.startsWith("memory:") ? path.slice("memory:".length) : readKeyFile(path);
|
|
117
|
+
}
|
|
118
|
+
/** Whether a wallet's key files are private to their owner (POSIX); a note per file that is not. */
|
|
119
|
+
export function walletFileProblems(wallet) {
|
|
120
|
+
if (process.platform === "win32")
|
|
121
|
+
return [];
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const role of wallet.roles) {
|
|
124
|
+
const path = wallet.files[role];
|
|
125
|
+
if (path.startsWith("memory:"))
|
|
126
|
+
continue;
|
|
127
|
+
const st = statSync(path);
|
|
128
|
+
if (st.mode & 0o077)
|
|
129
|
+
out.push(`${path} is readable by other users (mode ${(st.mode & 0o777).toString(8)}); chmod 600`);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DOCUMENT_FILES, buildDocuments, documentsDir, onChainState, publishSteps, readBack, readBuilt, readManifest } from "../node/documents.js";
|
|
3
|
+
import { ok, refused, safetyGate } from "../node/policy.js";
|
|
4
|
+
import { version } from "../util/version.js";
|
|
5
|
+
import { derivedJobId, zJobId } from "./common.js";
|
|
6
|
+
/**
|
|
7
|
+
* The cell's documents as capabilities (design memo, Phase 3, section 7): `docs_status` reads what is built and what
|
|
8
|
+
* the chain holds; `docs_publish` builds the documents from the configuration, commits each one that differs from
|
|
9
|
+
* the chain — the ERC-8004 agentURI, the ERC-8434 document URI and digest, the ERC-8444 descriptor, a new ERC-8454
|
|
10
|
+
* unit version — as one keyed job (the same documents are the same job, never sent twice), then reads them back.
|
|
11
|
+
* Metadata updates are the operator's own calls through the account (the `identity` scope group), plain actions
|
|
12
|
+
* that commit nothing and move nothing.
|
|
13
|
+
*/
|
|
14
|
+
const RowSchema = z.object({ commitment: z.string(), ok: z.boolean().nullable(), detail: z.string() });
|
|
15
|
+
export const docs_status = {
|
|
16
|
+
name: "docs_status",
|
|
17
|
+
title: "Documents: built and on chain",
|
|
18
|
+
description: "The cell's documents as last built (the manifest: files, content hashes, version) and what the chain holds for each commitment — agentURI, AID document, ARD descriptor, AUF unit version — with whether they agree.",
|
|
19
|
+
action: "read",
|
|
20
|
+
visibility: "public",
|
|
21
|
+
principal: "none",
|
|
22
|
+
ercs: [8004, 8434, 8444, 8454],
|
|
23
|
+
input: z.object({}),
|
|
24
|
+
output: z.object({
|
|
25
|
+
dir: z.string(),
|
|
26
|
+
manifest: z.unknown().nullable(),
|
|
27
|
+
onChain: z.object({ agentURI: z.string().nullable(), aid: z.object({ uri: z.string(), digest: z.string() }).nullable(), ard: z.object({ descriptorHash: z.string(), descriptorURI: z.string() }).nullable(), unit: z.object({ version: z.number(), boundaryHash: z.string() }).nullable() }),
|
|
28
|
+
readBack: z.array(RowSchema),
|
|
29
|
+
pending: z.array(z.string()),
|
|
30
|
+
}),
|
|
31
|
+
async handler({ node }) {
|
|
32
|
+
const dir = documentsDir(node);
|
|
33
|
+
const built = readBuilt(dir);
|
|
34
|
+
const onChain = await onChainState(node);
|
|
35
|
+
const rows = built ? await readBack(node, built, { fetchUrls: false }) : [];
|
|
36
|
+
const pending = built ? (await publishSteps(node, built)).steps.map((s) => s.name) : [];
|
|
37
|
+
return { dir, manifest: readManifest(dir), onChain, readBack: rows, pending };
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
export const docs_publish = {
|
|
41
|
+
name: "docs_publish",
|
|
42
|
+
title: "Publish the cell's documents",
|
|
43
|
+
description: "Build the documents from the configuration (registration, AID document, ARD descriptor, AUF unit, agent card, server.json) and commit on chain each one that differs from what the chain holds: setAgentURI, setDocumentURI, updateDescriptor, publishVersion. One keyed job; nothing is sent twice for the same documents; read back afterwards.",
|
|
44
|
+
action: "act",
|
|
45
|
+
visibility: "operator",
|
|
46
|
+
principal: "cell",
|
|
47
|
+
ercs: [8004, 8434, 8444, 8454],
|
|
48
|
+
input: z.object({ jobId: zJobId }),
|
|
49
|
+
output: z.object({ dir: z.string(), version: z.number(), documents: z.array(z.string()), committed: z.array(z.string()), unchanged: z.array(z.string()), readBack: z.array(RowSchema), jobId: z.string(), operations: z.array(z.string()) }),
|
|
50
|
+
async plan({ node }, input) {
|
|
51
|
+
const k = node.kernel;
|
|
52
|
+
const cell = node.body;
|
|
53
|
+
const built = await buildDocuments(node, { version });
|
|
54
|
+
const { steps: pending, unchanged } = await publishSteps(node, built);
|
|
55
|
+
const gates = [];
|
|
56
|
+
const [aidState, status] = await Promise.all([k.aid.stateName(cell.address), cell.status()]);
|
|
57
|
+
gates.push(safetyGate(aidState, status, "plain"));
|
|
58
|
+
if (!node.config.identity.agentId)
|
|
59
|
+
gates.push(refused("policy", "the cell has no ERC-8004 agent id: nothing to publish a registration for"));
|
|
60
|
+
else
|
|
61
|
+
gates.push(ok("policy", `${pending.length} commitment(s) to make, ${unchanged.length} already as built`));
|
|
62
|
+
const manifestHash = Object.values(built.manifest.documents).map((d) => d.contentHash).join(",");
|
|
63
|
+
const jobId = input.jobId ?? derivedJobId("docs", { hashes: manifestHash, uri: built.manifest.publicUrl ?? "data" });
|
|
64
|
+
const steps = pending.map((s) => ({ name: s.name, kind: "plain", build: () => ({ target: s.target, value: 0n, data: s.data, label: s.label }) }));
|
|
65
|
+
return {
|
|
66
|
+
job: { id: jobId, kind: "docs", input: { version: built.manifest.version, documents: built.manifest.documents, steps: pending.map((s) => s.name) } },
|
|
67
|
+
gates,
|
|
68
|
+
valueWei: 0n,
|
|
69
|
+
steps,
|
|
70
|
+
output: async (confirmed) => ({ dir: built.dir, version: built.manifest.version, documents: Object.values(DOCUMENT_FILES), committed: pending.map((s) => s.name), unchanged, readBack: await readBack(node, built, { fetchUrls: false }), jobId, operations: confirmed.map((c) => c.key) }),
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
export const DOCUMENT_CAPABILITIES = [docs_status, docs_publish];
|
package/dist/tools/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ADMIN_CAPABILITIES } from "./admin.js";
|
|
2
2
|
import { Catalog } from "./catalog.js";
|
|
3
|
+
import { DOCUMENT_CAPABILITIES } from "./documents.js";
|
|
3
4
|
import { IDENTITY_CAPABILITIES } from "./identity.js";
|
|
5
|
+
import { PROPAGATION_CAPABILITIES } from "./propagation.js";
|
|
4
6
|
import { READ_CAPABILITIES } from "./read.js";
|
|
5
7
|
import { TENDER_CAPABILITIES } from "./tenders.js";
|
|
6
8
|
export { Catalog, hashInput, succeeded } from "./catalog.js";
|
|
@@ -8,7 +10,7 @@ export * from "./types.js";
|
|
|
8
10
|
/** The catalog every entrance exposes: the Loop D path of the design memo's 43 capabilities, in Phase 3a. */
|
|
9
11
|
export function defaultCatalog() {
|
|
10
12
|
const c = new Catalog();
|
|
11
|
-
for (const cap of [...READ_CAPABILITIES, ...TENDER_CAPABILITIES, ...IDENTITY_CAPABILITIES, ...ADMIN_CAPABILITIES])
|
|
13
|
+
for (const cap of [...READ_CAPABILITIES, ...PROPAGATION_CAPABILITIES, ...TENDER_CAPABILITIES, ...IDENTITY_CAPABILITIES, ...DOCUMENT_CAPABILITIES, ...ADMIN_CAPABILITIES])
|
|
12
14
|
c.register(cap);
|
|
13
15
|
return c;
|
|
14
16
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { buildPropagationManifest, FOGA_COVENANTS, PROPAGATION_SCHEMA } from "../node/propagation.js";
|
|
3
|
+
import { version } from "../util/version.js";
|
|
4
|
+
/**
|
|
5
|
+
* `propagation_kit` (read, public, free): the material a covenant-complete cell hands any agent that wants to help
|
|
6
|
+
* FOGA spread — the replication pointer, the cell's free discovery coordinates, the program that makes a new cell.
|
|
7
|
+
* It is pure data, built from the configuration (no chain, no key, no cost): the agent decides where and how to use
|
|
8
|
+
* it, weaving the declinable pointer into its own interactions however it sees fit. There is no mandated channel.
|
|
9
|
+
* On-chain discoverability (the ERC-8444 ARD descriptor) remains the opt-in `docs_publish`; this is its free backup.
|
|
10
|
+
*/
|
|
11
|
+
export const propagation_kit = {
|
|
12
|
+
name: "propagation_kit",
|
|
13
|
+
title: "Propagation kit",
|
|
14
|
+
description: "The free, off-chain material for spreading FOGA: a declinable replication pointer any agent may weave into its own interactions, the cell's discovery coordinates, the ten covenants, and the one command that makes a new cell. Pure data — no chain, no key, no cost; the agent chooses where and how to use it.",
|
|
15
|
+
action: "read",
|
|
16
|
+
visibility: "public",
|
|
17
|
+
principal: "none",
|
|
18
|
+
ercs: [8338, 8414, 8419, 8434, 8436, 8444, 8447, 8454, 8468, 8471, 8004],
|
|
19
|
+
input: z.object({}),
|
|
20
|
+
output: z.object({
|
|
21
|
+
schema: z.literal(PROPAGATION_SCHEMA),
|
|
22
|
+
cell: z.object({ name: z.string(), account: z.string().nullable(), agentId: z.string().nullable(), chainId: z.number() }),
|
|
23
|
+
covenants: z.array(z.string()),
|
|
24
|
+
program: z.object({ package: z.string(), version: z.string(), becomeACell: z.string(), wallet: z.string(), docs: z.string(), site: z.string() }),
|
|
25
|
+
endpoints: z.object({ mcp: z.string().nullable(), a2a: z.string().nullable(), web: z.string().nullable(), card: z.string().nullable(), manifest: z.string().nullable() }),
|
|
26
|
+
replication: z.object({ pitch: z.string(), pointer: z.string(), optional: z.literal(true), consensual: z.literal(true) }),
|
|
27
|
+
skills: z.array(z.string()),
|
|
28
|
+
discovery: z.object({ free: z.array(z.string()), onchain: z.object({ optional: z.literal(true), via: z.string(), cost: z.string() }) }),
|
|
29
|
+
}),
|
|
30
|
+
async handler({ node }) {
|
|
31
|
+
return buildPropagationManifest(node, { version });
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
export { FOGA_COVENANTS };
|
|
35
|
+
export const PROPAGATION_CAPABILITIES = [propagation_kit];
|
package/dist/tools/tenders.js
CHANGED
|
@@ -4,7 +4,7 @@ import { getAddress, toUtf8Bytes, hexlify } from "ethers";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { accountRef, dataUri, dataUriJcs, keccakJcs, sha256Hex } from "../core/canonical.js";
|
|
6
6
|
import { kindId, taskDescriptor } from "../core/ard.js";
|
|
7
|
-
import { hashlock, nativeTerms } from "../core/tenders.js";
|
|
7
|
+
import { hashlock, nativeTerms, SubmissionStatus } from "../core/tenders.js";
|
|
8
8
|
import { runExtractIsolated, runProbeIsolated } from "../genesis/skill-runner.js";
|
|
9
9
|
import { ok, refused, safetyGate, skillGate, tenderGates } from "../node/policy.js";
|
|
10
10
|
import { GateSchema, TaskDocumentSchema, TenderOutputSchema, derivedJobId, readTender, tenderJobId, tenderOutput, zAs, zBool, zBytes32, zHex, zJobId, zUint } from "./common.js";
|
|
@@ -15,6 +15,14 @@ import { GateSchema, TaskDocumentSchema, TenderOutputSchema, derivedJobId, readT
|
|
|
15
15
|
* mode as calldata for its own account.
|
|
16
16
|
*/
|
|
17
17
|
const ZERO32 = "0x" + "00".repeat(32);
|
|
18
|
+
/**
|
|
19
|
+
* The skill worker's time budget: the configuration's (`skills.timeoutMs`), or a minute on a local chain and three
|
|
20
|
+
* on a public one — a shared endpoint answers a bounded log scan in seconds, not milliseconds, and a probe over a
|
|
21
|
+
* tender's lookback is several such scans.
|
|
22
|
+
*/
|
|
23
|
+
function skillTimeoutMs(node) {
|
|
24
|
+
return node.config.skills.timeoutMs ?? (node.world.network === "anvil" ? 60_000 : 180_000);
|
|
25
|
+
}
|
|
18
26
|
/** The text of a data: URI (base64 or percent-encoded), whatever its media type. */
|
|
19
27
|
function decodeDataUriText(uri) {
|
|
20
28
|
const m = /^data:([^,]*?)(;base64)?,(.*)$/s.exec(uri);
|
|
@@ -113,7 +121,7 @@ export const skill_run = {
|
|
|
113
121
|
let report;
|
|
114
122
|
if (input.skill === "foga-probe") {
|
|
115
123
|
const probe = input.input;
|
|
116
|
-
const run = await runProbeIsolated({ worldPath: node.worldPath, rpc: node.rpc, bundlePath: node.bundlePath, input: probe });
|
|
124
|
+
const run = await runProbeIsolated({ worldPath: node.worldPath, rpc: node.rpc, bundlePath: node.bundlePath, input: probe, timeoutMs: skillTimeoutMs(node) });
|
|
117
125
|
answer = run.answer;
|
|
118
126
|
report = run.report;
|
|
119
127
|
}
|
|
@@ -136,7 +144,7 @@ export const skill_run = {
|
|
|
136
144
|
throw new Error("input.document must be a bare file name inside the input directory");
|
|
137
145
|
documentPath = join(node.inputDir, doc);
|
|
138
146
|
}
|
|
139
|
-
const run = await runExtractIsolated({ documentPath, fieldsPath: join(node.skillsDir, "finch-extract-verify", "fields.json"), packageHash: skill.packageHash, inputDir, skillsDir: node.skillsDir });
|
|
147
|
+
const run = await runExtractIsolated({ documentPath, fieldsPath: join(node.skillsDir, "finch-extract-verify", "fields.json"), packageHash: skill.packageHash, inputDir, skillsDir: node.skillsDir, timeoutMs: skillTimeoutMs(node) });
|
|
140
148
|
answer = run.canonical;
|
|
141
149
|
report = run.report;
|
|
142
150
|
}
|
|
@@ -215,7 +223,12 @@ export const tender_settle = {
|
|
|
215
223
|
gates.push(refused("policy", "past settleBy"));
|
|
216
224
|
const jobId = input.jobId ?? `${tenderJobId(id, view.facts.completions)}:settle`;
|
|
217
225
|
const intent = { target: k.tenders.address, value: 0n, data: k.tenders.encodeSettle(id, sid, proof), label: `settleFulfillment(${input.tokenId}, ${input.submissionId})` };
|
|
218
|
-
|
|
226
|
+
// What the settlement pays is measured on the tender — the escrow it releases less what it leaves as credit —
|
|
227
|
+
// not as the account's net balance change: between the read before and the read after, another flow on the
|
|
228
|
+
// account (a tender the cell is minting, a withdrawal landing, a watcher's settlement of another tender) would
|
|
229
|
+
// leak into a balance delta and the evidence would record a reward the chain paid in full as a fraction of it.
|
|
230
|
+
// A rejected settlement releases the escrow to the demander and pays the cell nothing.
|
|
231
|
+
const [escrowBefore, creditBefore] = await Promise.all([k.tenders.escrowBalanceOf(id), k.tenders.creditOf(id, node.body.address)]);
|
|
219
232
|
return {
|
|
220
233
|
job: { id: jobId, kind: "tender-settle", input: { tokenId: input.tokenId, submissionId: input.submissionId, proofHash: sha256Hex(proof) } },
|
|
221
234
|
gates,
|
|
@@ -223,8 +236,11 @@ export const tender_settle = {
|
|
|
223
236
|
steps: [{ name: "settle", kind: "plain", build: () => intent }],
|
|
224
237
|
output: async (confirmed) => {
|
|
225
238
|
const c = confirmed[0];
|
|
226
|
-
const [
|
|
227
|
-
|
|
239
|
+
const [escrowAfter, creditAfter, s] = await Promise.all([k.tenders.escrowBalanceOf(id), k.tenders.creditOf(id, node.body.address), k.tenders.submissionOf(id, sid)]);
|
|
240
|
+
const released = escrowBefore > escrowAfter ? escrowBefore - escrowAfter : 0n;
|
|
241
|
+
const credited = creditAfter > creditBefore ? creditAfter - creditBefore : 0n;
|
|
242
|
+
const paid = s.status === SubmissionStatus.Accepted && released > credited ? released - credited : 0n;
|
|
243
|
+
return { tokenId: input.tokenId, submissionId: input.submissionId, txHash: c.txHash, operationId: `job:${jobId}:settle`, jobId, paidWei: paid.toString(), creditWei: creditAfter.toString(), status: s.status };
|
|
228
244
|
},
|
|
229
245
|
};
|
|
230
246
|
},
|
package/dist/util/version.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { repoRoot } from "./fs.js";
|
|
4
|
+
/** The npm package name (the binary stays `foga`; the scope is `finchip.ai`). */
|
|
5
|
+
export const PACKAGE_NAME = "@finchip.ai/foga";
|
|
4
6
|
/** The package version, read once from package.json (the same file the npm package ships). */
|
|
5
7
|
export const version = (() => {
|
|
6
8
|
try {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { loadBundle } from "../bundle/load.js";
|
|
4
|
+
import { readJson, repoRoot } from "../util/fs.js";
|
|
5
|
+
/**
|
|
6
|
+
* Source verification on Sourcify (design memo, section 8, the 2c leftover, and section 9, 3d-2): every contract a world deployed
|
|
7
|
+
* from the bundle is submitted with the very standard JSON input its compiler run took — the `build-info` files
|
|
8
|
+
* `forge build` leaves beside the artifacts (`.foga-build/out/<repo>/build-info/`, written by `npm run bundle:verify`
|
|
9
|
+
* and `foga build`) — the compiler version from the contract's metadata, and the creation transaction the world file
|
|
10
|
+
* recorded. Nothing is recompiled or rewritten for the verifier: what Sourcify checks is what the bundle was built
|
|
11
|
+
* from, at the locked commits. A dry run writes each request to a file instead of sending it; a chain Sourcify does
|
|
12
|
+
* not serve (anvil's 31337) is always a dry run.
|
|
13
|
+
*/
|
|
14
|
+
export const SOURCIFY_SERVER = "https://sourcify.dev/server";
|
|
15
|
+
/** Every full build-info file under the work directory, by repository. */
|
|
16
|
+
export function collectBuildInfo(outRoot = join(repoRoot(), ".foga-build", "out")) {
|
|
17
|
+
const out = [];
|
|
18
|
+
if (!existsSync(outRoot))
|
|
19
|
+
return out;
|
|
20
|
+
for (const repo of readdirSync(outRoot)) {
|
|
21
|
+
const dir = join(outRoot, repo, "build-info");
|
|
22
|
+
if (!existsSync(dir))
|
|
23
|
+
continue;
|
|
24
|
+
for (const name of readdirSync(dir).filter((f) => f.endsWith(".json"))) {
|
|
25
|
+
const b = JSON.parse(readFileSync(join(dir, name), "utf8"));
|
|
26
|
+
if (!b.input || !b.output?.contracts)
|
|
27
|
+
continue; // a minimal build-info (no --build-info): nothing to submit
|
|
28
|
+
out.push({ file: join(dir, name), repo, solcVersion: b.solcLongVersion ?? b.solcVersion ?? "", input: b.input, output: b.output });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function normalizeCode(hex) {
|
|
34
|
+
return (hex ?? "").toLowerCase().replace(/^0x/, "");
|
|
35
|
+
}
|
|
36
|
+
/** The compiler's long version (0.8.28+commit.7893614a) from a contract's metadata, the build-info's otherwise. */
|
|
37
|
+
function compilerVersionOf(meta, fallback) {
|
|
38
|
+
try {
|
|
39
|
+
const m = JSON.parse(meta ?? "{}");
|
|
40
|
+
return m.compiler?.version ?? fallback;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return fallback;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The verification requests of a world: for every step the world deployed from the bundle, the build-info whose
|
|
48
|
+
* output holds a contract of that name in that repository with the bundle's runtime code, the path solc saw as the
|
|
49
|
+
* identifier.
|
|
50
|
+
*/
|
|
51
|
+
export function verificationRequests(world, infos, o = {}) {
|
|
52
|
+
const bundle = loadBundle();
|
|
53
|
+
const requests = [];
|
|
54
|
+
const missing = [];
|
|
55
|
+
for (const step of world.steps.filter((s) => s.status === "deployed")) {
|
|
56
|
+
if (o.only && !o.only.includes(step.id))
|
|
57
|
+
continue;
|
|
58
|
+
const entry = world.contracts[step.id];
|
|
59
|
+
if (!entry?.address || !entry.bundleKey) {
|
|
60
|
+
missing.push({ id: step.id, reason: "the world file names no address or bundle key for this step" });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const contract = bundle.contracts[entry.bundleKey];
|
|
64
|
+
if (!contract) {
|
|
65
|
+
missing.push({ id: step.id, reason: `bundle key ${entry.bundleKey} is not in the committed bundle` });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const [repo, name] = entry.bundleKey.split(":");
|
|
69
|
+
const wanted = normalizeCode(contract.deployedBytecode);
|
|
70
|
+
let found = null;
|
|
71
|
+
for (const info of infos.filter((i) => i.repo === repo)) {
|
|
72
|
+
for (const [path, byName] of Object.entries(info.output.contracts)) {
|
|
73
|
+
const c = byName[name];
|
|
74
|
+
if (!c)
|
|
75
|
+
continue;
|
|
76
|
+
const code = normalizeCode(c.evm?.deployedBytecode?.object);
|
|
77
|
+
// immutables and the metadata hash make exact equality too strict only when they differ; the bundle was built the same way, so equality is the check
|
|
78
|
+
if (code && wanted && code !== wanted)
|
|
79
|
+
continue;
|
|
80
|
+
found = { id: step.id, address: entry.address, bundleKey: entry.bundleKey, creationTransactionHash: step.txHash ?? null, contractIdentifier: `${path}:${name}`, compilerVersion: compilerVersionOf(c.metadata, info.solcVersion), buildInfo: info.file, stdJsonInput: info.input };
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
if (found)
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
if (!found)
|
|
87
|
+
missing.push({ id: step.id, reason: `no build-info under .foga-build/out/${repo}/build-info holds ${name} with the bundle's runtime code (run npm run bundle:verify to produce the build-info files)` });
|
|
88
|
+
else
|
|
89
|
+
requests.push(found);
|
|
90
|
+
}
|
|
91
|
+
return { requests, missing };
|
|
92
|
+
}
|
|
93
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
94
|
+
/** Verify a world's deployed contracts on Sourcify, or write what would be sent. */
|
|
95
|
+
export async function verifyWorld(o) {
|
|
96
|
+
const log = o.log ?? (() => { });
|
|
97
|
+
const world = readJson(resolve(o.worldPath));
|
|
98
|
+
const server = (o.server ?? SOURCIFY_SERVER).replace(/\/+$/, "");
|
|
99
|
+
const served = ![31337, 1337].includes(world.chainId);
|
|
100
|
+
const dryRun = o.dryRun ?? !served;
|
|
101
|
+
if (!served && !dryRun)
|
|
102
|
+
throw new Error(`chain ${world.chainId} is a local chain Sourcify does not serve; use --dry-run`);
|
|
103
|
+
const infos = collectBuildInfo(o.buildInfoRoot);
|
|
104
|
+
const { requests, missing } = verificationRequests(world, infos, { only: o.only });
|
|
105
|
+
const outDir = resolve(o.outDir ?? join(repoRoot(), ".foga-build", "sourcify", world.name));
|
|
106
|
+
mkdirSync(outDir, { recursive: true });
|
|
107
|
+
const f = o.fetchImpl ?? fetch;
|
|
108
|
+
const results = [];
|
|
109
|
+
log(`sourcify: world ${world.worldId} (chain ${world.chainId}): ${requests.length} contract(s) to verify, ${missing.length} without a build-info; ${dryRun ? "dry run" : server}`);
|
|
110
|
+
for (const r of requests) {
|
|
111
|
+
const body = { stdJsonInput: r.stdJsonInput, compilerVersion: r.compilerVersion, contractIdentifier: r.contractIdentifier, ...(r.creationTransactionHash ? { creationTransactionHash: r.creationTransactionHash } : {}) };
|
|
112
|
+
const file = join(outDir, `${r.id.replace(/[^A-Za-z0-9._-]+/g, "_")}.json`);
|
|
113
|
+
writeFileSync(file, JSON.stringify({ chainId: world.chainId, address: r.address, ...body }, null, 2) + "\n");
|
|
114
|
+
if (dryRun) {
|
|
115
|
+
results.push({ id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: "dry-run", detail: `request written to ${file}` });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const existing = await f(`${server}/v2/contract/${world.chainId}/${r.address}`, { signal: AbortSignal.timeout(30_000) });
|
|
120
|
+
if (existing.ok) {
|
|
121
|
+
const e = (await existing.json());
|
|
122
|
+
if (e.match === "exact_match" || e.match === "match") {
|
|
123
|
+
results.push({ id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: "already", detail: `already verified (${e.match})`, creationMatch: e.creationMatch ?? null, runtimeMatch: e.runtimeMatch ?? null });
|
|
124
|
+
log(` ${r.id}: already verified (${e.match})`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const submitted = await f(`${server}/v2/verify/${world.chainId}/${r.address}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000) });
|
|
129
|
+
const ticket = (await submitted.json());
|
|
130
|
+
if (!submitted.ok || !ticket.verificationId) {
|
|
131
|
+
results.push({ id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: "error", detail: `HTTP ${submitted.status}: ${ticket.customCode ?? ""} ${ticket.message ?? ""}`.trim() });
|
|
132
|
+
log(` ${r.id}: refused — HTTP ${submitted.status} ${ticket.customCode ?? ""} ${ticket.message ?? ""}`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const started = Date.now();
|
|
136
|
+
let outcome = null;
|
|
137
|
+
while (Date.now() - started < (o.timeoutMs ?? 300_000)) {
|
|
138
|
+
await sleep(o.pollMs ?? 3000);
|
|
139
|
+
const status = await f(`${server}/v2/verify/${ticket.verificationId}`, { signal: AbortSignal.timeout(30_000) });
|
|
140
|
+
const job = (await status.json());
|
|
141
|
+
if (!job.isJobCompleted)
|
|
142
|
+
continue;
|
|
143
|
+
if (job.error)
|
|
144
|
+
outcome = { id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: "failed", detail: `${job.error.customCode ?? ""} ${job.error.message ?? ""}`.trim(), verificationId: ticket.verificationId };
|
|
145
|
+
else
|
|
146
|
+
outcome = { id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: job.contract?.match === "exact_match" ? "exact_match" : "match", detail: `creation ${job.contract?.creationMatch ?? "-"}, runtime ${job.contract?.runtimeMatch ?? "-"}`, verificationId: ticket.verificationId, creationMatch: job.contract?.creationMatch ?? null, runtimeMatch: job.contract?.runtimeMatch ?? null };
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
if (!outcome)
|
|
150
|
+
outcome = { id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: "error", detail: `verification ${ticket.verificationId} did not complete in time; check ${server}/v2/verify/${ticket.verificationId}`, verificationId: ticket.verificationId };
|
|
151
|
+
results.push(outcome);
|
|
152
|
+
log(` ${r.id}: ${outcome.outcome} — ${outcome.detail}`);
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
results.push({ id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: "error", detail: e.message });
|
|
156
|
+
log(` ${r.id}: error — ${e.message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const summary = {};
|
|
160
|
+
for (const r of results)
|
|
161
|
+
summary[r.outcome] = (summary[r.outcome] ?? 0) + 1;
|
|
162
|
+
const report = { schema: "foga.sourcify/1", worldId: world.worldId, chainId: world.chainId, server, dryRun, at: new Date().toISOString(), results, missing, summary };
|
|
163
|
+
writeFileSync(join(outDir, "report.json"), JSON.stringify(report, null, 2) + "\n");
|
|
164
|
+
return { report, outDir };
|
|
165
|
+
}
|
package/docs/a2a.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# A2A (POST /a2a, /.well-known/agent-card.json)
|
|
2
|
+
|
|
3
|
+
The fourth entrance (design memo, sections 7 and 9, 3d-2): the Agent2Agent protocol at version 1.0, the JSON-RPC binding only, on the node's HTTP server. Like MCP and the HTTP API it adds no rule of its own: an inbound message becomes work the catalog judges as the caller, and the economics stay on chain.
|
|
4
|
+
|
|
5
|
+
## The card
|
|
6
|
+
|
|
7
|
+
`GET /.well-known/agent-card.json` is the AgentCard (A2A 1.0, section 4.4), one of the documents `foga cell docs build` writes from the configuration (docs/documents.md): `name`, `description`, `supportedInterfaces: [{url: <publicUrl>/a2a, protocolBinding: "JSONRPC", protocolVersion: "1.0"}]` (empty until the cell has a public URL — the caller then uses the URL it knows), `provider` (`presence.provider`), `version` (the package's), `capabilities: {streaming: false, pushNotifications: false, extendedAgentCard: false}`, `securitySchemes` (`api-key`: `Bearer`; `wallet-signature`: `FOGA-EIP712`, docs/wallet.md), `defaultInputModes: ["text/plain", "application/json"]`, `defaultOutputModes: ["application/json", "text/plain"]`, and `skills`: the ERC-8338 skills the policy offers (`skills.offered`), each `id` the package name at its version, with two examples — a quote and a tender — as data parts. Not the token list, not a promise of anything the policy would refuse. The card is not signed (JWS stays for later).
|
|
8
|
+
|
|
9
|
+
## The binding
|
|
10
|
+
|
|
11
|
+
`POST /a2a` with `Content-Type: application/json` and `A2A-Version: 1.0` (or `?A2A-Version=1.0`); a JSON-RPC 2.0 request whose `method` is `SendMessage`, `GetTask`, `ListTasks` or `CancelTask`. An empty version is 0.3 by the specification and this agent does not speak it: `VersionNotSupportedError` (-32009). `SendStreamingMessage`, `SubscribeToTask` and `GetExtendedAgentCard` are `UnsupportedOperationError` (-32004), the push-notification methods `PushNotificationNotSupportedError` (-32003), anything else `MethodNotFoundError` (-32601); the error codes and their `data` (`google.rpc.ErrorInfo` with `reason`, `domain`, `metadata`) follow sections 5.4 and 9.5. Field names are camelCase and enums are their Protocol Buffer names (`TASK_STATE_COMPLETED`, `ROLE_USER`), as the specification prescribes for every JSON binding. Credentials are the HTTP server's (`Authorization`: nothing, a Bearer key, or a wallet signature); the caller a message comes from is what the node's access policy makes of them.
|
|
12
|
+
|
|
13
|
+
## Two roads
|
|
14
|
+
|
|
15
|
+
A message's data part may carry a `foga` object; a message of words is a quote.
|
|
16
|
+
|
|
17
|
+
**Quote** — `{"foga": {"mode": "quote", "skill": "foga-probe", "input": {…}}}`, or any text. Open to anyone. The task completes at once with a `quote` artifact: the cell's offered skills (name, version, package hash, token id, summary), the terms a tender needs to meet (the acceptance authority — the world's hashlock verifier — the reward floor, the submit and settle windows, the counterparty level), and how to proceed. A skill the cell does not offer is `TASK_STATE_REJECTED` with the list of what it does. Nothing runs and nothing touches the chain.
|
|
18
|
+
|
|
19
|
+
**Tender** — `{"foga": {"mode": "tender", "tokenId": "23"}}`, naming an ERC-8414 tender that exists, is funded, and has its answer committed to the hashlock verifier (the caller minted it for its own account — `tender_mint_build` gives the calls — or anyone did). The caller needs the act level: an API key the operator issued, or a wallet signature the cell's policy grants act to (with a request-bound envelope). The public is told to quote (`TASK_STATE_REJECTED`, gate `permission`). Without a `tokenId` the task waits (`TASK_STATE_INPUT_REQUIRED`) and a message with the `taskId` continues it. With one, the response is the task in `TASK_STATE_WORKING` and the work goes on after it (section 3.3.3): `tender_precheck`, then `skill_run` with the task document's input, then `tender_submit` — each through `Catalog.invoke` as the A2A caller, each a decision record under the caller's id. The task ends `TASK_STATE_COMPLETED` with a `submission` artifact (token id, result hash, answer hash, submission id, transaction hash, job and decision ids, the operations), `TASK_STATE_REJECTED` when a gate refused (the gate, the reason and the decision id in the status message's data part), or `TASK_STATE_FAILED` when the run failed. Settlement and withdrawal are the node's watchers' duty afterwards, as for every submission; the caller does not pay the cell through A2A and the cell does not pay the caller: the tender's escrow pays the account.
|
|
20
|
+
|
|
21
|
+
There is no road that does work first and talks about payment after, and no road that mints or funds a tender on the caller's behalf (who pays and who approves would need an authority this version does not define).
|
|
22
|
+
|
|
23
|
+
## Tasks
|
|
24
|
+
|
|
25
|
+
A task lives in the cell's store (`a2a.task.<id>` among the facts): visible to the caller that made it and to the operator's view, never to another caller (`GetTask` answers `TaskNotFoundError` rather than reveal it exists). The same `messageId` from the same caller is the same task (section 3.3.1). A message to a terminal task is `UnsupportedOperationError`. `ListTasks` pages one's own tasks (`pageSize` 1–100, `pageToken` as an offset, `status` and `contextId` filters, `includeArtifacts`); `CancelTask` ends a task that has not started working, asks a working one to stop between its steps, and refuses (`TaskNotCancelableError`) once the submission is broadcast or the task is terminal: what reached the chain stays.
|
|
26
|
+
|
|
27
|
+
## What the memo's v0.2 asked and where it landed
|
|
28
|
+
|
|
29
|
+
The signature covers the request content (the request-bound envelope hashes method, path and body); the request right and the act right are separate (a grant decides the level; the cell's own rules decide the act); task and operation are layered (one task, the catalog's jobs beneath it, named in the artifact); only existing, funded tenders are taken; duplicates are detected by (caller, messageId); `CancelTask` undoes nothing broadcast; the content type is `application/json`; `ListTasks` is implemented and scoped to the caller; `supportedInterfaces` lists the one binding; the data types are the proto's names and shapes (`src/a2a/types.ts`). Outbound A2A — the node calling other agents — is not in this phase: the node's work comes from the chain.
|
|
30
|
+
|
|
31
|
+
`test/a2a.test.ts` drives all of it on anvil with a raw JSON-RPC client; `foga quickstart` checks the card and a quote on every run.
|