Skip to content

Commit

The agents, models and context services are deleted. Nothing calls them any more: workspace agents, the hosted model proxy at models.g1t.sh with its AI Gateway, and the context hub with its search index are gone with the features they served. The deploy stack no longer builds or deploys them, so it no longer needs CONNECTORS_KEY, AI_GATEWAY_TOKEN, ANTHROPIC_API_KEY or WORKERS_AI_TOKEN, and a self-hosted install no longer generates CONNECTORS_KEY or stubs them out. Their databases, queues, index, gateway and domain stay in Cloudflare until they are deleted by hand after this is deployed.

syntaqxcommitted Parenta3f312cBrowse files
155 files+35−1904959 files not counted0/155 viewed
+6−9
1313 // instead of R2, the repos service its nightly backups (a bucket of
1414 // their own, BACKUP_S3_BUCKET), and the artifacts service the files in pages
1515 // (DOCS_S3_BUCKET);
16−// - services that are off in this phase (agents, the context hub, the
17−// g1t.page dispatcher, model proxy) are bound to workers/off instead, and
18−// events stop queueing work for them;
16+// - services that are off in this phase (the cloud runner, the g1t.page
17+// dispatcher) are bound to workers/off instead, and events stop queueing
18+// work for them;
1919 // - URLs that name g1t.sh name PUBLIC_URL instead, and billing is free.
2020 //
2121 // Usage: node configs.mjs [outDir]
2222 // Environment: PUBLIC_URL, GITSTORE_URL, GITSTORE_SECRET, MAIL_URL,
23−// ACTIONS_KEY, INTEGRATIONS_KEY, WEBHOOKS_KEY, IDENTITY_KEY, USERCONTENT_KEY, REPOS_KEY, CONNECTORS_KEY,
23+// ACTIONS_KEY, INTEGRATIONS_KEY, WEBHOOKS_KEY, IDENTITY_KEY, USERCONTENT_KEY, REPOS_KEY,
2424 // USERCONTENT_URL,
2525 // PACKAGES_TOKEN_SECRET, S3_ENDPOINT, S3_BUCKET, BACKUP_S3_BUCKET, DOCS_S3_BUCKET, S3_REGION,
2626 // S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_PUBLIC_ENDPOINT, and optionally
6969
7070 /** What the off Worker calls each service that is off in phase 1. */
7171 const OFF_NAMES = {
72− "g1t-runner": "Agents",
73− "g1t-context": "Context search and memory",
74− "g1t-models": "Hosted models",
72+ "g1t-runner": "Cloud runners",
7573 };
7674 const OFF = Object.fromEntries(
7775 STACK.filter((unit) => unit.self_host === "off").map((unit) => [unit.worker, OFF_NAMES[unit.worker] ?? unit.worker]),
8381 "g1t-repos": "REPOS_KEY",
8482 "g1t-actions": "ACTIONS_KEY",
8583 "g1t-integrations": "INTEGRATIONS_KEY",
86− "g1t-agents": "CONNECTORS_KEY",
8784 "g1t-webhooks": "WEBHOOKS_KEY",
8885 };
8986
109106 const SELF_HOST_CRONS = new Set(["g1t-repos", "g1t-events", "g1t-identity", "g1t-security", "g1t-webhooks", "g1t-packages", "g1t-artifacts"]);
110107
111108 /** Queues whose consumers are off: events stops sending to them. */
112−const OFF_QUEUES = new Set(["g1t-events-runner", "g1t-events-context"]);
109+const OFF_QUEUES = new Set(["g1t-events-runner"]);
113110
114111 /** Strips comments and trailing commas from JSONC. Strings are respected. */
115112 function parseJsonc(text) {
+1−1
1010 # repositories as bare repos on a volume (gitstore), the API in a second
1111 # workerd beside it, packages' files, backups and the clone pack cache in
1212 # RustFS (S3-compatible storage), and Mailpit for mail.
13−# Agents, deployments, context search and billing are off. See
13+# Cloud runners, deployments and billing are off. See
1414 # docs.g1t.sh/guides/self-hosting-architecture/.
1515 name: g1t
1616
+1−1
275275 node -e 'const q = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); const e = q.active.find((e) => e.number === Number(process.argv[2])); if (!e) process.exit(1); console.log(`#${e.number} is ${e.state} in the queue`)' "$WORK/api" "$QUEUE_PULL" \
276276 || fail "#$QUEUE_PULL is not in the queue: $(head -c 400 "$WORK/api")"
277277 [ "$(get "/$WORKSPACE/$REPO/queue")" = 200 ] && grep -q "Through the queue" "$WORK/body" || fail "queue page"
278−# Testing a queued state needs a sandbox, and agents are off: take it out.
278+# Testing a queued state needs a sandbox, and cloud runners are off: take it out.
279279 out="$(post "/$WORKSPACE/$REPO/pull/$QUEUE_PULL" --data-urlencode "action=unqueue")"
280280 echo "unqueue: $out"
281281 [ "$(api GET "/repos/$WORKSPACE/$REPO/queue")" = 200 ] || fail "queue"
+0−5
2828 if ! grep -q '^IDENTITY_KEY=' "$KEYS"; then
2929 echo "IDENTITY_KEY=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" >> "$KEYS"
3030 fi
31−# The agents service's key seals connectors' keys and OAuth tokens
32−# (Customize); added to keys made before it existed.
33−if ! grep -q '^CONNECTORS_KEY=' "$KEYS"; then
34− echo "CONNECTORS_KEY=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" >> "$KEYS"
35−fi
3631 # The site's key, which signs the short-lived addresses of private
3732 # repositories' files.
3833 if ! grep -q '^USERCONTENT_KEY=' "$KEYS"; then
+6−7
11 // A service that is turned off on this installation.
22 //
33 // Self-hosted phase 1 runs the core forge only. The site and the services
4−// still hold bindings to the runner (agents), the context hub and the
5−// deployments' builders; this Worker stands in for each of them, so a
6−// page that asks "are agents on here?" is told no instead of failing.
4+// still hold bindings to the cloud runner and the deployments' builders;
5+// this Worker stands in for each of them, so a page that asks whether the
6+// runner is on here is told no instead of failing.
77 //
8−// - As the runner (RunnerApi in packages/contracts/src/runner.ts): agents
9−// are not enabled, no model is reachable, and starting anything fails with
10−// a message that says why.
8+// - As the runner (RunnerApi in packages/contracts/src/runner.ts): it is
9+// not enabled, and starting anything fails with a message that says why.
1110 // - For the JSON protocol the Rust services speak (`POST /rpc/<method>`):
1211 // every method answers with a failed Result.
1312 //
14−// OFF_NAME names the feature in those messages, such as "Agents".
13+// OFF_NAME names the feature in those messages, such as "Cloud runners".
1514
1615 import { WorkerEntrypoint } from "cloudflare:workers";
1716
+4−45
1818 //
1919 // migrations: every pending D1 migration, before any code.
2020 // core: the services, reached through service bindings.
21− // edge: public endpoints other than the site: API, MCP, g1t.page, status
22− // (models is public too, but in core: agents binds to it).
21+ // edge: public endpoints other than the site: API, MCP, g1t.page, status.
2322 // front: the site, sudo and the docs.
2423 "stages": ["migrations", "core", "edge", "front"],
2524
147146 "The D1 database, before the first deploy: npx wrangler d1 create g1t-docs, then put its id in services/artifacts/wrangler.jsonc",
148147 "The R2 bucket for files in pages: npx wrangler r2 bucket create g1t-docs-files",
149148 "The queue the events service sends it merges and pushes on (pages whose cited code changed, projects' docs): npx wrangler queues create g1t-events-docs. The service also sends its own backfill jobs to it (JOBS)",
150− "The Vectorize index agents recall Docs from: npx wrangler vectorize create g1t-docs --dimensions=768 --metric=cosine, with string metadata indexes on workspace_id and space_id (npx wrangler vectorize create-metadata-index g1t-docs --property-name=<name> --type=string)",
151− "The Vectorize index for artifacts (folios), before the first deploy with FOLIO_VECTORS: npx wrangler vectorize create g1t-folios --dimensions=768 --metric=cosine, with string metadata indexes on workspace_id, scope and kind (npx wrangler vectorize create-metadata-index g1t-folios --property-name=<name> --type=string). Without it, artifacts are searched and recalled by words"
149+ "The Vectorize index Docs are searched by meaning from: npx wrangler vectorize create g1t-docs --dimensions=768 --metric=cosine, with string metadata indexes on workspace_id and space_id (npx wrangler vectorize create-metadata-index g1t-docs --property-name=<name> --type=string)",
150+ "The Vectorize index for artifacts (folios), before the first deploy with FOLIO_VECTORS: npx wrangler vectorize create g1t-folios --dimensions=768 --metric=cosine, with string metadata indexes on workspace_id, scope and kind (npx wrangler vectorize create-metadata-index g1t-folios --property-name=<name> --type=string). Without it, artifacts are searched by words"
152151 ],
153152 "self_host": "run"
154153 },
186185 ],
187186 "self_host": "run"
188187 },
189− "agents": {
190− "path": "services/agents",
191− "kind": "ts-worker",
192− "worker": "g1t-agents",
193− "d1": { "database": "g1t-agents", "migrations": "migrations" },
194− "stage": "core",
195− "secrets": ["CONNECTORS_KEY"],
196− "setup": [
197− "The D1 database, before the first deploy: npx wrangler d1 create g1t-agents, then put its id in services/agents/wrangler.jsonc"
198− ],
199− // Replies route and gate model work exactly as runs do, with the
200− // runner's own modules: its routing policy and who may use hosted models.
201− "inputs": ["services/runner/src/model-env.ts", "services/runner/src/hosted.ts"],
202− "self_host": "run"
203− },
204188 "billing": {
205189 "path": "services/billing",
206190 "kind": "rust-worker",
279263 "kind": "ts-worker",
280264 "worker": "g1t-runner",
281265 "stage": "core",
282− "secrets": ["AI_GATEWAY_TOKEN"],
266+ "secrets": [],
283267 "setup": [
284268 "Containers on the account; Docker on the machine that builds a new image",
285269 "The base image, once: node scripts/deploy.mjs build-base"
293277 },
294278 "self_host": "off"
295279 },
296− "context": {
297− "path": "services/context",
298− "kind": "ts-worker",
299− "worker": "g1t-context",
300− "d1": { "database": "g1t-context", "migrations": "migrations" },
301− "stage": "core",
302− "secrets": [],
303− "setup": [
304− "The Vectorize index: npx wrangler vectorize create g1t-context --dimensions=768 --metric=cosine, with metadata indexes on workspace, kind, project and private"
305− ],
306− "self_host": "off"
307− },
308280 "og": {
309281 "path": "services/og",
310282 "kind": "ts-worker",
331303 "The actions cache bucket's lifecycle rule limited to `c/`, so artifacts under `a/` are kept their retention-days (docs.g1t.sh/guides/deploy-to-cloudflare/#a-first-deploy-to-a-new-account)"
332304 ],
333305 "self_host": "separate"
334− },
335− "models": {
336− "path": "services/models",
337− "kind": "ts-worker",
338− "worker": "g1t-models",
339− // Core, though it is public at models.g1t.sh: the agents service
340− // reaches it by service binding for chat replies. It binds only to
341− // core services itself.
342− "stage": "core",
343− "secrets": ["AI_GATEWAY_TOKEN"],
344− "setup": ["The AI Gateway `g1t`"],
345− // Not run self-hosted, but bound to the off Worker: agents binds to it.
346− "self_host": "off"
347306 },
348307 "pages": {
349308 "path": "services/pages",
+0−0

Binary or large file; its contents are not shown.

+17−18
103103
104104 test("inside a stage, a Worker that does not exist yet goes before those bound to it", () => {
105105 const u = (id, worker, bindsTo = []) => ({ id, worker, bindsTo });
106− const artifacts = u("artifacts", "g1t-artifacts", ["g1t-agents", "g1t-identity"]);
107− const agents = u("agents", "g1t-agents", ["g1t-artifacts", "g1t-identity"]);
108− const chat = u("chat", "g1t-chat", ["g1t-agents"]);
106+ const artifacts = u("artifacts", "g1t-artifacts", ["g1t-notify", "g1t-identity"]);
107+ const notify = u("notify", "g1t-notify", ["g1t-artifacts", "g1t-identity"]);
108+ const chat = u("chat", "g1t-chat", ["g1t-notify"]);
109109 const ids = (list) => list.map((wave) => wave.map((x) => x.id));
110110 // Nothing new: one wave, as before.
111− assert.deepEqual(ids(waves([artifacts, agents, chat], () => false)), [["artifacts", "agents", "chat"]]);
111+ assert.deepEqual(ids(waves([artifacts, notify, chat], () => false)), [["artifacts", "notify", "chat"]]);
112112 // A renamed Worker: it first, then what binds to it.
113− assert.deepEqual(ids(waves([agents, chat, artifacts], (x) => x.id === "artifacts")), [["chat", "artifacts"], ["agents"]]);
113+ assert.deepEqual(ids(waves([notify, chat, artifacts], (x) => x.id === "artifacts")), [["chat", "artifacts"], ["notify"]]);
114114 // New Workers bound to each other: no order helps, so together.
115− assert.deepEqual(ids(waves([artifacts, agents], () => true)), [["artifacts", "agents"]]);
116− // This repository's agents unit binds to the artifacts Worker, in the same stage.
115+ assert.deepEqual(ids(waves([artifacts, notify], () => true)), [["artifacts", "notify"]]);
116+ // This repository's chat unit binds to the notify Worker, in the same stage.
117117 const real = (id) => stack.units.find((x) => x.id === id);
118− assert.equal(real("artifacts").stage, real("agents").stage);
119− assert.deepEqual(ids(waves([real("agents"), real("artifacts")], (x) => x.id === "artifacts")), [["artifacts"], ["agents"]]);
118+ assert.equal(real("chat").stage, real("notify").stage);
119+ assert.deepEqual(ids(waves([real("chat"), real("notify")], (x) => x.id === "notify")), [["notify"], ["chat"]]);
120120 });
121121
122122 test("Rust workers build with the shared script and the same wasm-opt level", () => {
412412 const atHead = { read: (path) => git.show(head, path), list: (dir) => git.list(head, dir) };
413413 const only = (u, file) => testOnlySource(file, unit(u).crateDirs, atHead);
414414 assert.ok(only("api", "apps/api/src/responses.rs"));
415− assert.ok(only("billing", "services/billing/src/catalogue_tests.rs"));
416− assert.ok(only("billing", "services/billing/src/gateway_tests.rs"));
417− assert.ok(!only("billing", "services/billing/src/catalogue.rs"));
415+ assert.ok(!only("billing", "services/billing/src/compute.rs"));
416+ assert.ok(!only("billing", "services/billing/src/limits.rs"));
418417 assert.ok(!only("api", "apps/api/src/toolkit.rs"));
419418 });
420419
422421 const units = ["actions", "billing", "runner"].map(unit);
423422 const live = { actions: { sha: OLD }, billing: { sha: OLD }, runner: { sha: OLD } };
424423 const files = {
425− [`${HEAD}:services/billing/src/gateway.rs`]: "#[cfg(test)]\n#[path = \"gateway_tests.rs\"]\nmod tests;\n",
426− [`${HEAD}:services/billing/src/lib.rs`]: "mod gateway;\n",
424+ [`${HEAD}:services/billing/src/compute.rs`]: "#[cfg(test)]\n#[path = \"compute_tests.rs\"]\nmod tests;\n",
425+ [`${HEAD}:services/billing/src/lib.rs`]: "mod compute;\n",
427426 };
428427 const gitApi = {
429− ...fakeGit(["crates/actions/tests/repository_workflows.rs", "services/billing/src/gateway_tests.rs"]),
428+ ...fakeGit(["crates/actions/tests/repository_workflows.rs", "services/billing/src/compute_tests.rs"]),
430429 show: (sha, path) => files[`${sha}:${path}`] ?? "",
431− list: (sha, dir) => Object.keys(files).map((k) => k.slice(sha.length + 1)).filter((p) => p.startsWith(`${dir}/`)).concat(dir === "services/billing/src" ? ["services/billing/src/gateway_tests.rs"] : []),
430+ list: (sha, dir) => Object.keys(files).map((k) => k.slice(sha.length + 1)).filter((p) => p.startsWith(`${dir}/`)).concat(dir === "services/billing/src" ? ["services/billing/src/compute_tests.rs"] : []),
432431 };
433432 const decisions = decide(units, { live, head: HEAD, gitApi });
434433 assert.deepEqual(decisions.map((d) => [d.unit.id, d.deploy, d.image]), [["actions", false, false], ["billing", false, false], ["runner", false, false]]);
435434 // With a source that ships beside them, only that is listed.
436− const mixed = decide([unit("billing")], { live, head: HEAD, gitApi: { ...gitApi, changed: () => ["services/billing/src/gateway_tests.rs", "services/billing/src/gateway.rs"] } });
437− assert.deepEqual([mixed[0].deploy, mixed[0].files], [true, ["services/billing/src/gateway.rs"]]);
435+ const mixed = decide([unit("billing")], { live, head: HEAD, gitApi: { ...gitApi, changed: () => ["services/billing/src/compute_tests.rs", "services/billing/src/compute.rs"] } });
436+ assert.deepEqual([mixed[0].deploy, mixed[0].files], [true, ["services/billing/src/compute.rs"]]);
438437 });
439438
440439 test("own folders, declared inputs and root files", () => {
+0−92
1−-- A workspace's own agents (docs/WORKSPACE.md, "Agents"): their
2−-- definitions, every version of them, each reply they gave and what it
3−-- cost, and spend rolled up by month and day for their budgets.
4−
5−-- One row per agent. Routing, budget and autonomy are small JSON objects
6−-- (@g1t/contracts workspace-agents.ts), read and written whole.
7−CREATE TABLE agents (
8− id TEXT PRIMARY KEY,
9− -- By id, not slug: a renamed workspace keeps its agents.
10− workspace_id TEXT NOT NULL,
11− handle TEXT NOT NULL,
12− display_name TEXT NOT NULL,
13− avatar TEXT,
14− role TEXT NOT NULL,
15− instructions TEXT NOT NULL,
16− personality_preset TEXT NOT NULL DEFAULT 'crisp',
17− personality TEXT NOT NULL DEFAULT '',
18− routing TEXT NOT NULL,
19− budget TEXT NOT NULL,
20− autonomy TEXT NOT NULL,
21− capacity INTEGER NOT NULL DEFAULT 3,
22− template TEXT,
23− version INTEGER NOT NULL DEFAULT 1,
24− -- The desk's presence: a reply is in flight until this time. A time, not
25− -- a flag, so a desk that died mid-reply never leaves it "working".
26− busy_until TEXT,
27− created_by TEXT NOT NULL,
28− created_at TEXT NOT NULL,
29− updated_at TEXT NOT NULL,
30− archived_at TEXT
31−);
32−
33−-- A handle is unique among a workspace's agents that are not archived:
34−-- archiving one frees its handle, and its old messages still resolve by id.
35−CREATE UNIQUE INDEX agents_handle ON agents (workspace_id, handle) WHERE archived_at IS NULL;
36−CREATE INDEX agents_workspace ON agents (workspace_id, archived_at);
37−
38−-- Every version of every definition, as it was saved, for the profile's
39−-- history and for runs to say which version they ran.
40−CREATE TABLE agent_versions (
41− agent_id TEXT NOT NULL,
42− version INTEGER NOT NULL,
43− definition TEXT NOT NULL,
44− changed_by TEXT NOT NULL,
45− created_at TEXT NOT NULL,
46− PRIMARY KEY (agent_id, version)
47−);
48−
49−-- Each message an agent was handed, and what came of it: spend and audit.
50−-- status: working, replied, failed, blocked (budget or access; a short
51−-- notice was posted, or kept back as a repeat), skipped (hop limit).
52−CREATE TABLE agent_replies (
53− id TEXT PRIMARY KEY,
54− agent_id TEXT NOT NULL,
55− workspace_id TEXT NOT NULL,
56− channel_id TEXT NOT NULL,
57− -- The message that woke it; one reply per message, however often it is handed over.
58− message_id TEXT NOT NULL,
59− -- The message the agent posted, when it posted one.
60− reply_id TEXT,
61− asked_by TEXT,
62− agent_version INTEGER,
63− model TEXT,
64− tier TEXT,
65− input_tokens INTEGER NOT NULL DEFAULT 0,
66− output_tokens INTEGER NOT NULL DEFAULT 0,
67− -- What the model cost at the provider's price, in millionths of a dollar.
68− cost_micros INTEGER NOT NULL DEFAULT 0,
69− -- What it counts against the agent's budget: the model at price plus
70− -- the agent rate. Billing's ledger, with comped terms and discounts, is
71− -- what the workspace is actually charged.
72− charged_micros INTEGER NOT NULL DEFAULT 0,
73− status TEXT NOT NULL,
74− error TEXT,
75− created_at TEXT NOT NULL,
76− finished_at TEXT
77−);
78−
79−CREATE UNIQUE INDEX agent_replies_message ON agent_replies (agent_id, message_id);
80−CREATE INDEX agent_replies_recent ON agent_replies (agent_id, created_at);
81−CREATE INDEX agent_replies_channel ON agent_replies (agent_id, channel_id, status, created_at);
82−
83−-- Spend by agent and period: `YYYY-MM` for the month, `YYYY-MM-DD` for the
84−-- day, both UTC. Budgets and `spent_month_micros` read these, never a sum
85−-- over every reply.
86−CREATE TABLE agent_spend (
87− agent_id TEXT NOT NULL,
88− period TEXT NOT NULL,
89− micros INTEGER NOT NULL DEFAULT 0,
90− replies INTEGER NOT NULL DEFAULT 0,
91− PRIMARY KEY (agent_id, period)
92−);
+0−43
1−-- Every workspace's built-in orchestrator, @g1t (docs/WORKSPACE.md, "g1t,
2−-- the orchestrator"): an ordinary agent row marked builtin, made the first
3−-- time the workspace's agents are asked for. One per workspace.
4−ALTER TABLE agents ADD COLUMN builtin INTEGER NOT NULL DEFAULT 0;
5−
6−CREATE UNIQUE INDEX agents_builtin ON agents (workspace_id) WHERE builtin = 1;
7−
8−-- Each agent's face: the seed its generated pixel avatar is drawn from,
9−-- set from the handle when it is made. Empty on older rows: the handle.
10−ALTER TABLE agents ADD COLUMN avatar_seed TEXT NOT NULL DEFAULT '';
11−
12−-- Agents are hired into roles, not tasks (docs/WORKSPACE.md, "Roles, not
13−-- tasks"): a title, a team (by slug) or a department label, a list of
14−-- responsibilities and the subagents they keep (JSON lists), and whether
15−-- they face the workspace's own people or customers (only `internal` yet).
16−ALTER TABLE agents ADD COLUMN title TEXT NOT NULL DEFAULT '';
17−ALTER TABLE agents ADD COLUMN team TEXT;
18−ALTER TABLE agents ADD COLUMN department TEXT NOT NULL DEFAULT '';
19−ALTER TABLE agents ADD COLUMN responsibilities TEXT NOT NULL DEFAULT '[]';
20−ALTER TABLE agents ADD COLUMN subagents TEXT NOT NULL DEFAULT '[]';
21−ALTER TABLE agents ADD COLUMN faces TEXT NOT NULL DEFAULT 'internal';
22−
23−-- Every read tool call a reply made (docs/WORKSPACE.md, "What an agent can
24−-- and can't know", rule 7): the agent, who asked, a fingerprint of the
25−-- audience, the tool and its arguments (long text cut), and whether it was
26−-- read or withheld, with how much came back.
27−CREATE TABLE agent_tool_calls (
28− id TEXT PRIMARY KEY,
29− reply_id TEXT NOT NULL,
30− agent_id TEXT NOT NULL,
31− workspace_id TEXT NOT NULL,
32− asked_by TEXT,
33− tool TEXT NOT NULL,
34− args TEXT NOT NULL,
35− audience_hash TEXT NOT NULL,
36− -- allowed, withheld, refused (a rail: the call budget, the hop limit, no ping-pong) or error.
37− outcome TEXT NOT NULL,
38− bytes INTEGER NOT NULL DEFAULT 0,
39− created_at TEXT NOT NULL
40−);
41−
42−CREATE INDEX agent_tool_calls_reply ON agent_tool_calls (reply_id);
43−CREATE INDEX agent_tool_calls_agent ON agent_tool_calls (agent_id, created_at);
+0−211
1−-- Sessions, memory, routines and the workspace's agent policy
2−-- (docs/WORKSPACE.md, "Sessions", "Memory", "Routines", "Budgets").
3−
4−-- One row per session: a bounded piece of work an agent took on. Talking
5−-- to an agent is a reply (agent_replies); real work is a session, with its
6−-- own context, transcript, cap and live card. Sessions form trees: a
7−-- session starts others for its subagents or for colleagues it brings in,
8−-- and the whole tree is paid by the agent at its root (payer_agent_id).
9−CREATE TABLE agent_sessions (
10− id TEXT PRIMARY KEY,
11− workspace_id TEXT NOT NULL,
12− agent_id TEXT NOT NULL,
13− -- The subagent running it, by name, for kind `subagent`.
14− subagent TEXT,
15− -- chat, routine, helper or subagent.
16− kind TEXT NOT NULL,
17− parent_id TEXT,
18− root_id TEXT NOT NULL,
19− payer_agent_id TEXT NOT NULL,
20− title TEXT NOT NULL,
21− goal TEXT NOT NULL,
22− -- queued, working, waiting, needs_approval, done, failed or stopped.
23− status TEXT NOT NULL,
24− status_note TEXT,
25− summary TEXT,
26− -- Where it was asked and where it reports, and who asked with what access
27− -- (the asker's access caps everything it reads and does).
28− workspace TEXT NOT NULL,
29− channel_id TEXT NOT NULL,
30− channel_kind TEXT NOT NULL,
31− channel_name TEXT,
32− -- The conversation's thread the request was in (null: top level).
33− thread_root TEXT,
34− message_id TEXT,
35− card_message_id TEXT,
36− asked_by TEXT,
37− asked_by_username TEXT,
38− asker TEXT,
39− routine_id TEXT,
40− -- The agents that handed this work along, oldest first (hop limit, no ping-pong).
41− chain TEXT NOT NULL DEFAULT '[]',
42− hops INTEGER NOT NULL DEFAULT 0,
43− -- The model's working context: a bounded list of turns, compacted as it
44− -- grows. The full record is agent_session_events.
45− context TEXT NOT NULL DEFAULT '[]',
46− -- What arrived while it worked (steering, helpers' results), read at its next step.
47− inbox TEXT NOT NULL DEFAULT '[]',
48− steps INTEGER NOT NULL DEFAULT 0,
49− tool_calls INTEGER NOT NULL DEFAULT 0,
50− input_tokens INTEGER NOT NULL DEFAULT 0,
51− output_tokens INTEGER NOT NULL DEFAULT 0,
52− cost_micros INTEGER NOT NULL DEFAULT 0,
53− charged_micros INTEGER NOT NULL DEFAULT 0,
54− cap_micros INTEGER,
55− model TEXT,
56− outputs TEXT NOT NULL DEFAULT '[]',
57− -- When its current step started: a step that never finished is picked up again.
58− step_started_at TEXT,
59− created_at TEXT NOT NULL,
60− updated_at TEXT NOT NULL,
61− finished_at TEXT
62−);
63−
64−CREATE INDEX agent_sessions_agent ON agent_sessions (agent_id, created_at);
65−CREATE INDEX agent_sessions_workspace ON agent_sessions (workspace_id, status, created_at);
66−CREATE INDEX agent_sessions_root ON agent_sessions (root_id);
67−CREATE INDEX agent_sessions_parent ON agent_sessions (parent_id);
68−CREATE INDEX agent_sessions_card ON agent_sessions (card_message_id);
69−CREATE INDEX agent_sessions_payer ON agent_sessions (payer_agent_id, created_at);
70−
71−-- A session's transcript, as its page shows it: what it was asked, what
72−-- it said, every tool it used (arguments cut, never results), steering,
73−-- updates it posted, sessions it started, and its report.
74−CREATE TABLE agent_session_events (
75− session_id TEXT NOT NULL,
76− seq INTEGER NOT NULL,
77− kind TEXT NOT NULL,
78− by_name TEXT,
79− body TEXT NOT NULL,
80− tool TEXT,
81− outcome TEXT,
82− created_at TEXT NOT NULL,
83− PRIMARY KEY (session_id, seq)
84−);
85−
86−-- What an agent remembers, each fact with its source and a scope that
87−-- decides where it may be recalled: workspace, a channel, or one person's
88−-- direct messages (scope_ref: the channel or user id).
89−CREATE TABLE agent_memories (
90− id TEXT PRIMARY KEY,
91− agent_id TEXT NOT NULL,
92− workspace_id TEXT NOT NULL,
93− scope TEXT NOT NULL,
94− scope_ref TEXT NOT NULL DEFAULT '',
95− scope_label TEXT,
96− body TEXT NOT NULL,
97− source_kind TEXT NOT NULL,
98− source_ref TEXT,
99− source_label TEXT,
100− source_channel_id TEXT,
101− created_by TEXT NOT NULL,
102− created_by_kind TEXT NOT NULL,
103− pinned INTEGER NOT NULL DEFAULT 0,
104− created_at TEXT NOT NULL,
105− updated_at TEXT NOT NULL
106−);
107−
108−CREATE INDEX agent_memories_agent ON agent_memories (agent_id, scope, scope_ref);
109−
110−-- Work an agent does on a schedule: each run is a session in the routine's
111−-- channel, with the access of the person who set it up (sponsor).
112−CREATE TABLE agent_routines (
113− id TEXT PRIMARY KEY,
114− agent_id TEXT NOT NULL,
115− workspace_id TEXT NOT NULL,
116− -- The workspace's slug when it was saved, for posting and billing.
117− workspace TEXT NOT NULL,
118− name TEXT NOT NULL,
119− instructions TEXT NOT NULL,
120− -- JSON: every (hour, day, weekday, week), minute, hour, weekday. UTC.
121− -- Null when it runs only on events.
122− schedule TEXT,
123− -- JSON lists: what it runs on (pull_ready, checks_failed...), and which
124− -- repositories those come from (workspace/name; empty: any its sponsor can read).
125− events TEXT NOT NULL DEFAULT '[]',
126− repos TEXT NOT NULL DEFAULT '[]',
127− channel_id TEXT NOT NULL,
128− channel_name TEXT,
129− sponsor TEXT NOT NULL,
130− sponsor_username TEXT,
131− enabled INTEGER NOT NULL DEFAULT 1,
132− paused_note TEXT,
133− next_run_at TEXT,
134− last_run_at TEXT,
135− last_session_id TEXT,
136− runs INTEGER NOT NULL DEFAULT 0,
137− created_at TEXT NOT NULL,
138− updated_at TEXT NOT NULL
139−);
140−
141−CREATE INDEX agent_routines_agent ON agent_routines (agent_id);
142−CREATE INDEX agent_routines_due ON agent_routines (enabled, next_run_at);
143−CREATE INDEX agent_routines_workspace ON agent_routines (workspace_id, enabled);
144−
145−-- What each routine ran on, so one thing that happened runs it once.
146−CREATE TABLE agent_routine_runs (
147− routine_id TEXT NOT NULL,
148− -- The event's kind and what it was about: pull_ready:rep_1:12.
149− run_key TEXT NOT NULL,
150− session_id TEXT,
151− created_at TEXT NOT NULL,
152− PRIMARY KEY (routine_id, run_key)
153−);
154−CREATE INDEX agent_routine_runs_recent ON agent_routine_runs (routine_id, created_at);
155−
156−-- The workspace's say over all its agents together.
157−CREATE TABLE agent_policies (
158− workspace_id TEXT PRIMARY KEY,
159− monthly_micros INTEGER,
160− default_agent_monthly_micros INTEGER,
161− default_session_micros INTEGER NOT NULL DEFAULT 2000000,
162− updated_by TEXT,
163− updated_at TEXT NOT NULL
164−);
165−
166−-- Every agent's spend together, by workspace and period (YYYY-MM, YYYY-MM-DD),
167−-- for the workspace's agent budget; and which alerts went out this month.
168−CREATE TABLE workspace_agent_spend (
169− workspace_id TEXT NOT NULL,
170− period TEXT NOT NULL,
171− micros INTEGER NOT NULL DEFAULT 0,
172− alerted INTEGER NOT NULL DEFAULT 0,
173− PRIMARY KEY (workspace_id, period)
174−);
175−
176−-- Replies say who they were for, where, and how many tools they used, for
177−-- the spend breakdown and the Activity tab.
178−ALTER TABLE agent_replies ADD COLUMN asked_by_username TEXT;
179−ALTER TABLE agent_replies ADD COLUMN channel_name TEXT;
180−ALTER TABLE agent_replies ADD COLUMN tool_count INTEGER NOT NULL DEFAULT 0;
181−-- Session steps counted with the agent's spend, beside replies.
182−ALTER TABLE agent_spend ADD COLUMN sessions INTEGER NOT NULL DEFAULT 0;
183−
184−-- Issues an agent drafted in a conversation, as cards with File and
185−-- Discard: whoever presses File files it as themselves, if they can read
186−-- the repository. status: draft, filed, discarded.
187−CREATE TABLE agent_drafts (
188− id TEXT PRIMARY KEY,
189− agent_id TEXT NOT NULL,
190− workspace_id TEXT NOT NULL,
191− workspace TEXT NOT NULL,
192− channel_id TEXT NOT NULL,
193− message_id TEXT,
194− session_id TEXT,
195− repo_id TEXT NOT NULL,
196− repo TEXT NOT NULL,
197− title TEXT NOT NULL,
198− body TEXT NOT NULL,
199− labels TEXT NOT NULL DEFAULT '[]',
200− asked_by TEXT,
201− status TEXT NOT NULL DEFAULT 'draft',
202− filed_by TEXT,
203− number INTEGER,
204− created_at TEXT NOT NULL,
205− updated_at TEXT NOT NULL
206−);
207−CREATE INDEX agent_drafts_message ON agent_drafts (message_id);
208−
209−-- Each agent's required reading: Docs spaces (a JSON list of ids) it
210−-- checks first whenever it answers or works.
211−ALTER TABLE agents ADD COLUMN reading TEXT NOT NULL DEFAULT '[]';
+0−23
1−-- Budgets per person (docs.g1t.sh/guides/agent-budgets/): what the agents
2−-- working for one person, the replies and sessions that person asked for,
3−-- may spend together in a month. Budgets nest: the workspace's spend limit
4−-- (billing), the workspace's agent budget, a person's, an agent's, a task's.
5−
6−-- The budget every person has unless they have their own. Null: none.
7−ALTER TABLE agent_policies ADD COLUMN person_monthly_micros INTEGER;
8−
9−-- A person's own budget, set by an owner. 0: no budget at all, whatever
10−-- the default. No row: the default.
11−CREATE TABLE person_budgets (
12− workspace_id TEXT NOT NULL,
13− username TEXT NOT NULL,
14− monthly_micros INTEGER NOT NULL,
15− updated_by TEXT NOT NULL,
16− updated_at TEXT NOT NULL,
17− PRIMARY KEY (workspace_id, username)
18−);
19−
20−-- What a person's agents spent is summed from the replies and sessions they
21−-- asked for, so it counts from the first one, before any budget was set.
22−CREATE INDEX agent_replies_asker ON agent_replies (workspace_id, asked_by_username, created_at);
23−CREATE INDEX agent_sessions_asker ON agent_sessions (workspace_id, asked_by_username, created_at);
+0−53
1−-- The Marketplace: members' install requests (src/installs.ts), where a
2−-- member asks the workspace's owners to add an agent from the catalog, an
3−-- integration or an extension; and the extensions a workspace installed
4−-- (src/extensions.ts).
5−
6−CREATE TABLE install_requests (
7− id TEXT PRIMARY KEY,
8− -- By id, not slug: a renamed workspace keeps its requests.
9− workspace_id TEXT NOT NULL,
10− -- `agent:<template>`, `integration:<connector>` or `extension:<id>` (@g1t/contracts marketplace.ts).
11− listing TEXT NOT NULL,
12− -- The listing's name when it was asked for, so a request still reads
13− -- after the listing changes.
14− name TEXT NOT NULL,
15− note TEXT,
16− requested_by TEXT NOT NULL,
17− requested_by_id TEXT NOT NULL,
18− requested_at TEXT NOT NULL,
19− -- open, done or declined.
20− status TEXT NOT NULL DEFAULT 'open',
21− resolved_by TEXT,
22− resolved_at TEXT
23−);
24−
25−-- One open request per person and listing: asking twice is one request.
26−CREATE UNIQUE INDEX install_requests_open ON install_requests (workspace_id, listing, requested_by_id) WHERE status = 'open';
27−CREATE INDEX install_requests_by_workspace ON install_requests (workspace_id, status, requested_at);
28−
29−-- Extensions installed in a workspace: which version, on which plan, who
30−-- installed it, and whether it is switched on. An uninstalled extension
31−-- keeps its row, with when and by whom.
32−CREATE TABLE extension_installs (
33− id TEXT PRIMARY KEY,
34− workspace_id TEXT NOT NULL,
35− -- `extension:<id>`.
36− listing TEXT NOT NULL,
37− version TEXT NOT NULL,
38− -- `free` until listings have prices.
39− plan TEXT NOT NULL DEFAULT 'free',
40− installed_by TEXT NOT NULL,
41− installed_at TEXT NOT NULL,
42− -- 1 on, 0 off: the kill switch.
43− enabled INTEGER NOT NULL DEFAULT 1,
44− disabled_by TEXT,
45− disabled_at TEXT,
46− -- A monthly cap on what it spends, in micro-dollars; null: the workspace's limit.
47− budget_monthly_micros INTEGER,
48− uninstalled_by TEXT,
49− uninstalled_at TEXT
50−);
51−
52−-- One live install per workspace and extension.
53−CREATE UNIQUE INDEX extension_installs_live ON extension_installs (workspace_id, listing) WHERE uninstalled_at IS NULL;
+0−6
1−-- Agent skills (docs.g1t.sh/guides/agent-skills/): every agent starts with
2−-- g1t's foundational skills (@g1t/contracts skills.ts), and a workspace's
3−-- owners can turn any of them off for one agent. Part of the definition,
4−-- so a change is a new version like any other: a JSON list of skill ids,
5−-- empty for all of them on.
6−ALTER TABLE agents ADD COLUMN skills_off TEXT NOT NULL DEFAULT '[]';
+0−18
1−-- Personal agents (docs.g1t.sh/guides/agents/, "Personal agents"): any
2−-- member may create an agent of their own, which only they talk to, in
3−-- their direct message with it, and whose spend counts against their
4−-- budget. Owners keep the workspace's agents, and promote a personal one
5−-- to a workspace agent.
6−
7−-- `workspace` or `personal`. Every agent so far is the workspace's.
8−ALTER TABLE agents ADD COLUMN scope TEXT NOT NULL DEFAULT 'workspace';
9−-- For a personal agent: the member it belongs to, by user id (what chat
10−-- names the asker by) and username (what lists show).
11−ALTER TABLE agents ADD COLUMN owner_id TEXT;
12−ALTER TABLE agents ADD COLUMN owner_username TEXT;
13−
14−CREATE INDEX agents_personal ON agents (workspace_id, owner_id, archived_at) WHERE owner_id IS NOT NULL;
15−
16−-- Whether members who aren't owners may create personal agents: on unless
17−-- an owner turns it off.
18−ALTER TABLE agent_policies ADD COLUMN members_create_agents INTEGER NOT NULL DEFAULT 1;
+0−50
1−-- Effort and recommendations (docs.g1t.sh/guides/spend/).
2−--
3−-- An agent's effort setting lives in its routing JSON (`effort`: auto,
4−-- low, medium, high or max), so it is versioned with the rest of the
5−-- definition and needs no column. What is recorded here is the level each
6−-- piece of work actually ran at, so what a level costs and how often its
7−-- work is accepted can be measured from real history.
8−
9−-- The level a session ran at: low, medium, high or max. When Auto raises
10−-- it partway (someone steered it), the highest. Null before this.
11−ALTER TABLE agent_sessions ADD COLUMN effort TEXT;
12−-- The tier its last step ran on, for the record.
13−ALTER TABLE agent_sessions ADD COLUMN tier TEXT;
14−-- The level a chat reply ran at.
15−ALTER TABLE agent_replies ADD COLUMN effort TEXT;
16−
17−CREATE INDEX IF NOT EXISTS agent_sessions_finished ON agent_sessions (workspace_id, agent_id, finished_at);
18−
19−-- Ways to spend less, from the weekly check (src/recommend.ts). One row per
20−-- agent and suggestion; a check refreshes an open or thin one in place, and
21−-- never reopens one that was applied or dismissed until the agent's
22−-- setting changes. status: open, thin (too little history to say),
23−-- applied, dismissed, stale (the setting changed under it).
24−CREATE TABLE agent_recommendations (
25− id TEXT PRIMARY KEY,
26− workspace_id TEXT NOT NULL,
27− agent_id TEXT NOT NULL,
28− kind TEXT NOT NULL,
29− status TEXT NOT NULL,
30− from_effort TEXT NOT NULL,
31− to_effort TEXT NOT NULL,
32− title TEXT NOT NULL,
33− reason TEXT NOT NULL,
34− -- JSON: the window and both sides' counts, acceptance and costs.
35− evidence TEXT NOT NULL,
36− saving_month_micros INTEGER,
37− checked_at TEXT NOT NULL,
38− resolved_by TEXT,
39− resolved_at TEXT,
40− created_at TEXT NOT NULL
41−);
42−CREATE UNIQUE INDEX agent_recommendations_one ON agent_recommendations (agent_id, kind, from_effort, to_effort);
43−CREATE INDEX agent_recommendations_workspace ON agent_recommendations (workspace_id, status);
44−
45−-- When the check last ran for each workspace, so the page can say so and
46−-- the daily run checks each workspace once a week.
47−CREATE TABLE agent_recommendation_checks (
48− workspace_id TEXT PRIMARY KEY,
49− checked_at TEXT NOT NULL
50−);
+0−78
1−-- The skill library (docs.g1t.sh/guides/agent-skills/): a workspace's own
2−-- skills, each a SKILL.md folder (@g1t/contracts skill-format.ts), every
3−-- change a new version, attached to agents, teams or the whole workspace.
4−
5−-- One row per skill: its newest version's summary, for lists. A draft
6−-- (saved from a session) waits for a person to publish it and can't be
7−-- attached. `mirrored` skills follow the repository in skill_mirrors.
8−CREATE TABLE skills (
9− id TEXT PRIMARY KEY,
10− workspace_id TEXT NOT NULL,
11− name TEXT NOT NULL,
12− status TEXT NOT NULL DEFAULT 'published',
13− version INTEGER NOT NULL,
14− description TEXT NOT NULL,
15− tools TEXT NOT NULL DEFAULT '[]',
16− requires_computer INTEGER NOT NULL DEFAULT 0,
17− files INTEGER NOT NULL DEFAULT 0,
18− bytes INTEGER NOT NULL DEFAULT 0,
19− -- Where the newest version came from (SkillOrigin, JSON).
20− origin TEXT NOT NULL,
21− mirrored INTEGER NOT NULL DEFAULT 0,
22− created_by TEXT NOT NULL,
23− created_at TEXT NOT NULL,
24− updated_by TEXT NOT NULL,
25− updated_at TEXT NOT NULL,
26− archived_at TEXT
27−);
28−CREATE UNIQUE INDEX skills_name ON skills (workspace_id, name) WHERE archived_at IS NULL;
29−
30−-- Every version as written: SKILL.md and the folder's other files (JSON
31−-- SkillFile[], text as it is and anything else as base64), at most 1 MB.
32−-- `digest` tells an unchanged import or push from a new version.
33−CREATE TABLE skill_versions (
34− skill_id TEXT NOT NULL,
35− version INTEGER NOT NULL,
36− description TEXT NOT NULL,
37− tools TEXT NOT NULL DEFAULT '[]',
38− requires_computer INTEGER NOT NULL DEFAULT 0,
39− skill_md TEXT NOT NULL,
40− files TEXT NOT NULL DEFAULT '[]',
41− bytes INTEGER NOT NULL,
42− origin TEXT NOT NULL,
43− note TEXT,
44− digest TEXT NOT NULL,
45− created_by TEXT NOT NULL,
46− created_at TEXT NOT NULL,
47− PRIMARY KEY (skill_id, version)
48−);
49−
50−-- Where a skill is attached, and the version its agents use there.
51−-- `target` is the agent's id, the team's slug, or '' for the workspace.
52−CREATE TABLE skill_attachments (
53− id TEXT PRIMARY KEY,
54− workspace_id TEXT NOT NULL,
55− skill_id TEXT NOT NULL,
56− scope TEXT NOT NULL,
57− target TEXT NOT NULL DEFAULT '',
58− version INTEGER NOT NULL,
59− attached_by TEXT NOT NULL,
60− attached_at TEXT NOT NULL
61−);
62−CREATE UNIQUE INDEX skill_attachments_once ON skill_attachments (skill_id, scope, target);
63−CREATE INDEX skill_attachments_reach ON skill_attachments (workspace_id, scope, target);
64−
65−-- The repository a workspace's library follows: `.g1t/skills/<name>/` on
66−-- its default branch, read when it is linked and after every push there.
67−CREATE TABLE skill_mirrors (
68− workspace_id TEXT PRIMARY KEY,
69− repo_id TEXT NOT NULL,
70− repo TEXT NOT NULL,
71− branch TEXT NOT NULL,
72− commit_sha TEXT,
73− synced_at TEXT,
74− error TEXT,
75− linked_by TEXT NOT NULL,
76− linked_at TEXT NOT NULL
77−);
78−CREATE INDEX skill_mirrors_repo ON skill_mirrors (repo_id);
+0−17
1−-- An agent is on teams the way a person is: through team membership in
2−-- identity (team_agents), added and removed on the team page or the
3−-- agent's profile (docs.g1t.sh/guides/people-and-teams/). The agent itself
4−-- carries no team and no department.
5−--
6−-- agents.team and agents.department stay as they are, unused: migrations
7−-- run before the code, so the code still serving during a deploy reads
8−-- and writes them for a minute or more. A later migration drops them once
9−-- no deployed code names them.
10−--
11−-- Each agent's old `team` moves to a membership once (src/team-move.ts,
12−-- run by the cron): a team of that slug in its workspace gets it as a
13−-- member, and a value that names no team is dropped and logged.
14−-- `team_moved_at` marks the agents it has seen, so it never runs twice for
15−-- one, and taking an agent off that team later stays taken off.
16−ALTER TABLE agents ADD COLUMN team_moved_at TEXT;
17−CREATE INDEX IF NOT EXISTS agents_team_to_move ON agents (id) WHERE team IS NOT NULL AND team <> '' AND team_moved_at IS NULL;
+0−40
1−-- Abilities (docs.g1t.sh/guides/agent-abilities/): what each agent may do
2−-- through g1t, the workspace's integrations and MCP servers an owner adds,
3−-- and whether it does each alone, only when asked for it, after asking
4−-- first, or never.
5−
6−-- The agent's choices (@g1t/contracts abilities.ts `AgentAbilities`, JSON):
7−-- levels and credentials per ability, and its MCP servers with the tools
8−-- each listed. Saved as a version like every other change.
9−ALTER TABLE agents ADD COLUMN abilities TEXT NOT NULL DEFAULT '{}';
10−
11−-- An ability set to "Ask first", asked: the call an agent wanted to make,
12−-- waiting on the person it acts for (or an owner) to allow it from the
13−-- card in chat. Allowed, the call runs as that person and the result goes
14−-- back to the agent (a session reads it at its next step).
15−-- status: pending, allowed, denied, failed.
16−CREATE TABLE agent_ability_requests (
17− id TEXT PRIMARY KEY,
18− agent_id TEXT NOT NULL,
19− workspace_id TEXT NOT NULL,
20− workspace TEXT NOT NULL,
21− channel_id TEXT NOT NULL,
22− message_id TEXT,
23− session_id TEXT,
24− -- The ability's id (integration:linear:comment) and the tool call it was for.
25− ability TEXT NOT NULL,
26− tool TEXT NOT NULL,
27− input TEXT NOT NULL,
28− -- What it would do, in a line, as the card says it.
29− summary TEXT NOT NULL,
30− asked_by TEXT,
31− status TEXT NOT NULL DEFAULT 'pending',
32− decided_by TEXT,
33− decided_at TEXT,
34− -- What came of it, as the agent was told.
35− result TEXT,
36− created_at TEXT NOT NULL,
37− updated_at TEXT NOT NULL
38−);
39−CREATE INDEX agent_ability_requests_session ON agent_ability_requests (session_id, status);
40−CREATE INDEX agent_ability_requests_agent ON agent_ability_requests (agent_id, created_at);
+0−5
1−-- Each agent's face as its owner chose it (docs.g1t.sh/guides/agents/, "Its
2−-- face"): a JSON object of seven parts (@g1t/contracts agent-look.ts
3−-- `AgentLook`), read and written whole. NULL means the agent wears the face
4−-- drawn from its avatar_seed, as every agent did before this.
5−ALTER TABLE agents ADD COLUMN look TEXT;
+0−5
1−-- Who archived an agent (docs.g1t.sh/guides/agents/, "Archive, restore and
2−-- delete"), by username, so the Archived section and an archived agent's
3−-- direct message can say so without reading the audit log. NULL for an
4−-- agent archived before this column, and for a live one.
5−ALTER TABLE agents ADD COLUMN archived_by TEXT;
+0−109
1−-- Customize (docs.g1t.sh/guides/customize/): marketplaces a workspace
2−-- reads plugins from, the plugins it installed, and its connectors (MCP
3−-- servers connected once and given to agents). Formats are the open ones
4−-- Claude Code reads (@g1t/contracts claude-plugin.ts). Requests stay in
5−-- install_requests (0005), whose listings now name plugins, skills and
6−-- connectors too. extension_installs (0005) is no longer written.
7−
8−-- A marketplace: where it is (MarketplaceSource, JSON) and its manifest as
9−-- last read (marketplace.json, JSON), so Discover lists from the cache.
10−-- The built-in marketplaces (claude-plugins-official, anthropic-agent-skills)
11−-- have no row of their own per workspace: their manifests are cached
12−-- under workspace_id '' and read by every workspace.
13−CREATE TABLE marketplaces (
14− id TEXT PRIMARY KEY,
15− workspace_id TEXT NOT NULL,
16− name TEXT NOT NULL,
17− source TEXT NOT NULL,
18− manifest TEXT,
19− fetched_at TEXT,
20− -- Why the manifest couldn't be read last time, if so.
21− problem TEXT,
22− added_by TEXT NOT NULL,
23− added_at TEXT NOT NULL
24−);
25−CREATE UNIQUE INDEX marketplaces_name ON marketplaces (workspace_id, name);
26−
27−-- A plugin installed in a workspace: `name@marketplace`, where its files
28−-- were read from (FetchPlan, JSON) and at which commit, what it brought
29−-- (PluginComponents, JSON), and whether it is switched on. Uninstalling
30−-- keeps the row with when and by whom.
31−CREATE TABLE plugin_installs (
32− id TEXT PRIMARY KEY,
33− workspace_id TEXT NOT NULL,
34− ref TEXT NOT NULL,
35− name TEXT NOT NULL,
36− marketplace TEXT NOT NULL,
37− display_name TEXT,
38− description TEXT NOT NULL DEFAULT '',
39− version TEXT,
40− sha TEXT,
41− fetch TEXT NOT NULL,
42− components TEXT NOT NULL DEFAULT '{}',
43− -- The subagents in full (SubagentFile[], JSON), as hireable templates.
44− agents TEXT NOT NULL DEFAULT '[]',
45− problems TEXT NOT NULL DEFAULT '[]',
46− enabled INTEGER NOT NULL DEFAULT 1,
47− installed_by TEXT NOT NULL,
48− installed_at TEXT NOT NULL,
49− updated_at TEXT NOT NULL,
50− uninstalled_by TEXT,
51− uninstalled_at TEXT
52−);
53−CREATE UNIQUE INDEX plugin_installs_live ON plugin_installs (workspace_id, ref) WHERE uninstalled_at IS NULL;
54−
55−-- A library skill that came with a plugin keeps the plugin's ref, and its
56−-- name is unique within that plugin rather than the library: agents call
57−-- it as `plugin:name`.
58−ALTER TABLE skills ADD COLUMN plugin TEXT;
59−DROP INDEX skills_name;
60−CREATE UNIQUE INDEX skills_name ON skills (workspace_id, COALESCE(plugin, ''), name) WHERE archived_at IS NULL;
61−
62−-- A connector: an MCP server the workspace connected. Its secrets (a
63−-- header's value, OAuth tokens and the client registration) are sealed
64−-- with AES-GCM under CONNECTORS_KEY, bound to the connector's id, and
65−-- never returned. `from` says where it came from (ConnectorFrom, JSON);
66−-- `tools` is the server's tool list as last read (McpTool[], JSON).
67−CREATE TABLE connectors (
68− id TEXT PRIMARY KEY,
69− workspace_id TEXT NOT NULL,
70− name TEXT NOT NULL,
71− title TEXT NOT NULL,
72− url TEXT NOT NULL,
73− category TEXT,
74− about TEXT,
75− -- none, header or oauth.
76− auth TEXT NOT NULL DEFAULT 'none',
77− header_name TEXT,
78− -- Sealed JSON: { header?: value, oauth?: { client_id, client_secret?, access_token, refresh_token?, expires_at?, token_endpoint, scopes? } }.
79− secrets TEXT,
80− -- What the server said about who signed in, when the sign-in was done.
81− signed_in_as TEXT,
82− signed_in_at TEXT,
83− -- An OAuth sign-in in progress: the state, the PKCE verifier and the endpoints, sealed; cleared when it completes.
84− pending TEXT,
85− pending_at TEXT,
86− from_json TEXT NOT NULL,
87− tools TEXT NOT NULL DEFAULT '[]',
88− checked_at TEXT,
89− problem TEXT,
90− added_by TEXT NOT NULL,
91− added_at TEXT NOT NULL,
92− updated_at TEXT NOT NULL,
93− removed_at TEXT
94−);
95−CREATE UNIQUE INDEX connectors_name ON connectors (workspace_id, name) WHERE removed_at IS NULL;
96−
97−-- Where a connector reaches: every agent (scope workspace, target ''), a
98−-- team's agents (team, the team's slug), or one agent (agent, its id).
99−CREATE TABLE connector_grants (
100− id TEXT PRIMARY KEY,
101− workspace_id TEXT NOT NULL,
102− connector_id TEXT NOT NULL,
103− scope TEXT NOT NULL,
104− target TEXT NOT NULL DEFAULT '',
105− granted_by TEXT NOT NULL,
106− granted_at TEXT NOT NULL
107−);
108−CREATE UNIQUE INDEX connector_grants_once ON connector_grants (connector_id, scope, target);
109−CREATE INDEX connector_grants_reach ON connector_grants (workspace_id, scope, target);
+0−15
1−{
2− "name": "@g1t/agents",
3− "version": "0.1.0",
4− "private": true,
5− "type": "module",
6− "license": "MIT",
7− "scripts": {
8− "test": "node --test src/*.test.ts",
9− "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json",
10− "deploy": "wrangler deploy"
11− },
12− "dependencies": {
13− "@g1t/contracts": "*"
14− }
15−}
+0−43
1−/**
2− * What the prompt says about an agent's abilities outside g1t
3− * (abilities.ts), and what a turn "said" for "alone when asked for it".
4− * Pure, with type-only imports, so it is tested under Node as it is.
5− */
6−import type { Ability, AbilitySection } from "@g1t/contracts";
7−
8−/**
9− * The abilities as the prompt tells the agent about them: what each
10− * connected integration lets it do and at which level, its MCP servers,
11− * and what isn't connected, so it asks rather than guesses.
12− */
13−export function abilitiesSection(sections: AbilitySection[]): string | null {
14− const lines: string[] = [];
15− const words = (ability: Ability) => (ability.level === "alone" ? "on your own" : ability.level === "asked" ? "on your own only when they asked for it, else it asks first" : ability.level === "ask" ? "asks first (a card)" : "never");
16− for (const section of sections) {
17− if (section.group !== "integration" && section.group !== "mcp") continue;
18− for (const source of section.sources) {
19− if (!source.connected) continue;
20− const ready = source.abilities.filter((ability) => ability.status === "ready");
21− if (!ready.length) continue;
22− lines.push(`- ${source.name}: ${ready.map((ability) => `${ability.label.toLowerCase()} ${words(ability)}`).join("; ")}.`);
23− }
24− }
25− const missing = sections
26− .filter((section) => section.group === "integration")
27− .flatMap((section) => section.sources)
28− .filter((source) => !source.connected && source.abilities.length)
29− .map((source) => source.name);
30− if (!lines.length && !missing.length) return null;
31− return [
32− "## Your abilities outside g1t",
33− "",
34− "These are set on your Abilities tab and enforced in code: a call that isn't allowed is refused with the rule, and one that asks first posts a card. Never work around a refusal.",
35− ...(lines.length ? ["", ...lines] : []),
36− ...(missing.length ? ["", `Not connected to this workspace: ${missing.join(", ")}. When someone needs one, say so and use request_ability.`] : []),
37− ].join("\n");
38−}
39−
40−/** What a turn says, for "alone when asked for it": the latest messages from people, or a session's goal and steering. */
41−export function saidText(parts: (string | null | undefined)[]): string {
42− return parts.filter((part): part is string => typeof part === "string" && part.trim().length > 0).join("\n").slice(0, 20_000);
43−}
+0−364
1−import assert from "node:assert/strict";
2−import { test } from "node:test";
3−
4−import type { AbilitySection, McpServer, User } from "@g1t/contracts";
5−
6−import { resolveAbilities, withSetting } from "../../../packages/contracts/src/abilities.ts";
7−import { CONNECTORS } from "../../../packages/contracts/src/connectors.ts";
8−import { abilitiesSection, saidText } from "./abilities-prompt.ts";
9−import { Audience, type AudienceInfo, type AudiencePorts, type RepoRef } from "./audience.ts";
10−import { abilityCard, connectCard, requestCard } from "./card-views.ts";
11−import { DEFAULT_AUTONOMY, applyChanges } from "./definition.ts";
12−import { callMcpTool, listMcpTools } from "./mcp-client.ts";
13−import { type AbilityPorts, type ActionPorts, type ToolPorts, ToolBox } from "./tools.ts";
14−
15−// ── A small world ────────────────────────────────────────────────────────
16−
17−const WEB: RepoRef = { id: "rep_web", namespace: "acme", name: "web", isPrivate: true, defaultBranch: "main" };
18−const person = (id: string): User => ({ id, username: id, workspaces: [{ slug: "acme", role: "member" }] }) as User;
19−
20−function world(): AudiencePorts {
21− const info: AudienceInfo = { kind: "dm", member_user_ids: ["asker"], member_count: 1 };
22− return {
23− info: async () => info,
24− users: async (ids) => [person("asker")].filter((u) => ids.includes(u.id)),
25− workspaceRepos: async () => [WEB],
26− readable: async (ids) => [WEB].filter((r) => ids.includes(r.id)),
27− };
28−}
29−
30−const ports: ToolPorts = {
31− readFile: async () => null,
32− searchCode: async () => [],
33− listIssues: async () => [],
34− getIssue: async () => null,
35− getPull: async () => null,
36− recentPulls: async () => [],
37− searchMessages: async () => [],
38− readThread: async () => null,
39− roster: async () => "",
40− consult: async () => ({ ok: false, message: "no" }),
41−};
42−
43−const actions: ActionPorts = {
44− remember: async () => ({ ok: true, message: "" }),
45− forget: async () => ({ ok: true, message: "" }),
46− draftIssue: async () => ({ ok: true, message: "" }),
47−};
48−
49−const ITEMS: Record<string, { provider: string; key: string; title: string; url: string; status: string | null; body: string }> = {
50− "ENG-42": { provider: "linear", key: "ENG-42", title: "Retry flaky checks", url: "https://linear.app/acme/issue/ENG-42", status: "In Progress", body: "The queue retries." },
51− "TECH-7": { provider: "jira", key: "TECH-7", title: "Login timeout", url: "https://acme.atlassian.net/browse/TECH-7", status: "To Do", body: "Times out." },
52−};
53−
54−type Posted = { kind: string; title: string };
55−
56−/** Ports that record what they were asked to do. */
57−function fakeAbilityPorts(over: Partial<AbilityPorts> = {}): AbilityPorts & { posted: Posted[]; acted: string[]; refusals: string[] } {
58− const posted: Posted[] = [];
59− const acted: string[] = [];
60− const refusals: string[] = [];
61− return {
62− posted,
63− acted,
64− refusals,
65− lookup: async (_asker, reference) => (ITEMS[reference] ? { ok: true, value: ITEMS[reference]! } : { ok: false, code: "not_found", message: "None of the acme workspace's integrations knows that." }),
66− import: async (_asker, repo, reference) => ({ ok: true, value: { number: 7, item: ITEMS[reference]!, created: true } }),
67− act: async (_asker, reference, action, text) => {
68− acted.push(`${action} ${reference}: ${text}`);
69− return { ok: true, value: ITEMS[reference]! };
70− },
71− askerConnected: async () => false,
72− mcp: async (server, tool, args) => {
73− acted.push(`mcp ${server.name}.${tool.name} ${JSON.stringify(args)}`);
74− return { ok: true, value: "sunny" };
75− },
76− askFirst: async ({ summary }) => {
77− posted.push({ kind: "ability", title: summary });
78− return "abr_1";
79− },
80− connect: async (source) => {
81− posted.push({ kind: "connect", title: source.name });
82− return true;
83− },
84− request: async ({ connector, ability }) => {
85− posted.push({ kind: "request", title: connector ?? ability?.id ?? "" });
86− return true;
87− },
88− refused: ({ rule }) => {
89− refusals.push(rule);
90− },
91− ...over,
92− };
93−}
94−
95−const SERVER: McpServer = {
96− id: "mcp_w",
97− name: "weather",
98− url: "https://mcp.example.com/",
99− tools: [
100− { name: "get_forecast", description: "Today's forecast", kind: "read", input_schema: { type: "object", properties: { city: { type: "string" } } } },
101− { name: "set_alert", description: "Set an alert", kind: "write", input_schema: { type: "object", properties: {} } },
102− ],
103− added_by: "ana",
104− added_at: "2026-10-10T00:00:00.000Z",
105− checked_at: null,
106− problem: null,
107−};
108−
109−async function box(input: { connected?: string[]; settings?: Record<string, { level?: "alone" | "asked" | "ask" | "never"; credentials?: "workspace" | "asker" }>; servers?: McpServer[]; said?: string; session?: boolean; ports?: Partial<AbilityPorts> }) {
110− const audience = await Audience.build("acme", "asker", world());
111− const tools = new ToolBox(audience, ports, { agentId: "agt_me", notConsult: ["me"], hops: 0, maxHops: 6, session: input.session }, [], actions);
112− let abilities = { settings: {}, mcp_servers: input.servers ?? [] };
113− for (const [id, setting] of Object.entries(input.settings ?? {})) abilities = withSetting(abilities, id, setting);
114− const sections: AbilitySection[] = resolveAbilities({ connectors: CONNECTORS, abilities, autonomy: DEFAULT_AUTONOMY, connected: input.connected ?? [] });
115− const fake = fakeAbilityPorts(input.ports);
116− tools.useAbilities(sections, fake, input.said ?? "", input.servers ?? []);
117− return { tools, fake, sections };
118−}
119−
120−const names = (tools: ToolBox) => tools.definitions().map((t) => t.name);
121−
122−// ── Offering ─────────────────────────────────────────────────────────────
123−
124−test("nothing connected: no outside tools but request_ability; a connected Linear offers lookup, import and act", async () => {
125− const none = await box({});
126− assert.ok(!names(none.tools).includes("lookup_outside"));
127− assert.ok(names(none.tools).includes("request_ability"), "it can still ask for what it lacks");
128− const linear = await box({ connected: ["linear"] });
129− for (const tool of ["lookup_outside", "import_outside", "act_outside"]) assert.ok(names(linear.tools).includes(tool), tool);
130−});
131−
132−test("an ability set to Never isn't offered when it is the only one for its tool, and is refused with the rule if called anyway", async () => {
133− const { tools, fake } = await box({ connected: ["linear"], settings: { "integration:linear:comment": { level: "never" } } });
134− assert.ok(!names(tools).includes("act_outside"), "Linear's only act ability is Never");
135− const tried = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "hi" });
136− assert.equal(tried.outcome, "refused");
137− assert.match(tried.text, /no tool called act_outside/);
138− assert.deepEqual(fake.acted, []);
139−});
140−
141−test("Never on one system, allowed on another: the tool is offered, and the rule is named per call, in the result and the audit log", async () => {
142− const { tools, fake } = await box({ connected: ["linear", "jira"], settings: { "integration:linear:comment": { level: "never" }, "integration:jira:comment": { level: "alone" } } });
143− assert.ok(names(tools).includes("act_outside"));
144− const linear = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "Looks fixed." });
145− assert.equal(linear.outcome, "refused");
146− assert.match(linear.text, /Not allowed: your abilities say "Linear: Comment" is Never/);
147− assert.deepEqual(fake.refusals, ["integration:linear:comment=never"]);
148− assert.deepEqual(fake.acted, [], "nothing ran");
149− const jira = await tools.run("act_outside", { reference: "TECH-7", action: "comment", text: "On it." });
150− assert.equal(jira.outcome, "allowed");
151− assert.deepEqual(fake.acted, ["comment TECH-7: On it."]);
152− assert.equal(tools.calls.length, 2, "both calls are in the transcript");
153− assert.equal(tools.calls[0]!.outcome, "refused");
154−});
155−
156−test("reading is alone by default; an item nobody knows is not found, and a system that isn't connected is said so", async () => {
157− const { tools } = await box({ connected: ["linear"] });
158− const read = await tools.run("lookup_outside", { reference: "ENG-42" });
159− assert.equal(read.outcome, "allowed");
160− assert.match(read.text, /<untrusted source="Linear ENG-42">/);
161− assert.match(read.text, /Retry flaky checks/);
162− const missing = await tools.run("lookup_outside", { reference: "NOPE-1" });
163− assert.equal(missing.outcome, "refused");
164− assert.match(missing.text, /No connected integration knows NOPE-1/);
165− // Jira knows TECH-7, but Jira isn't connected to this workspace as the agent sees it.
166− const jira = await tools.run("lookup_outside", { reference: "TECH-7" });
167− assert.equal(jira.outcome, "refused");
168− assert.match(jira.text, /Jira isn't connected to this workspace, so you can't read tickets there/);
169−});
170−
171−// ── Ask first, and alone when asked for it ───────────────────────────────
172−
173−test("Ask first posts the card with the call, runs nothing, and tells the agent to wait; a session is told it pauses", async () => {
174− const { tools, fake } = await box({ connected: ["linear"], session: true });
175− const tried = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "Fixed in #412." });
176− assert.equal(tried.outcome, "refused");
177− assert.match(tried.text, /"Linear: Comment" is after asking first: a card was posted asking @asker \(or an owner\)/);
178− assert.match(tried.text, /Your session pauses/);
179− assert.deepEqual(fake.posted, [{ kind: "ability", title: "Comment on ENG-42 in Linear" }]);
180− assert.deepEqual(fake.acted, []);
181− assert.deepEqual(fake.refusals, ["integration:linear:comment=ask"]);
182−});
183−
184−test("alone when asked for it: runs when the person named the item, asks first when they didn't", async () => {
185− const asked = await box({ connected: ["linear"], said: "Please import ENG-42 into web so we can track it here." });
186− const ran = await asked.tools.run("import_outside", { repo: "web", reference: "ENG-42" });
187− assert.equal(ran.outcome, "allowed");
188− assert.match(ran.text, /Opened acme\/web#7 from ENG-42/);
189− assert.deepEqual(asked.fake.posted, []);
190− const unasked = await box({ connected: ["linear"], said: "What's the state of the queue work?" });
191− const held = await unasked.tools.run("import_outside", { repo: "web", reference: "ENG-42" });
192− assert.equal(held.outcome, "refused");
193− assert.match(held.text, /^Import issues in Linear runs on its own only when asked for it, and this wasn't\. "Linear: Import issues" is only when the person asked for that: a card was posted/);
194− assert.deepEqual(unasked.fake.posted, [{ kind: "ability", title: "Import ENG-42 from Linear into acme/web" }]);
195−});
196−
197−test("when the card can't be posted, the agent is told to ask in words", async () => {
198− const { tools } = await box({ connected: ["linear"], ports: { askFirst: async () => null } });
199− const tried = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "x" });
200− assert.match(tried.text, /couldn't be posted just now/);
201−});
202−
203−// ── Whose connection ─────────────────────────────────────────────────────
204−
205−test("the asker's own connection, missing: a Connect card, nothing runs, and the audit log says why", async () => {
206− const { tools, fake } = await box({ connected: ["linear"], settings: { "integration:linear:read": { credentials: "asker" } } });
207− const tried = await tools.run("lookup_outside", { reference: "ENG-42" });
208− assert.equal(tried.outcome, "refused");
209− assert.match(tried.text, /runs on @asker's own Linear connection, and they haven't connected one. A Connect card was posted/);
210− assert.deepEqual(fake.posted, [{ kind: "connect", title: "Linear" }]);
211− assert.deepEqual(fake.refusals, ["integration:linear:read=asker-not-connected"]);
212− const connected = await box({ connected: ["linear"], settings: { "integration:linear:read": { credentials: "asker" } }, ports: { askerConnected: async () => true } });
213− assert.equal((await connected.tools.run("lookup_outside", { reference: "ENG-42" })).outcome, "allowed");
214−});
215−
216−// ── Requests ─────────────────────────────────────────────────────────────
217−
218−test("request_ability posts a Request card for an integration that isn't connected, or an ability by its id", async () => {
219− const { tools, fake } = await box({ connected: ["linear"], settings: { "integration:linear:comment": { level: "never" } } });
220− const jira = await tools.run("request_ability", { needs: "jira", why: "Support files bugs there." });
221− assert.equal(jira.outcome, "allowed");
222− const comment = await tools.run("request_ability", { needs: "integration:linear:comment", why: "To tell the team when it's fixed." });
223− assert.equal(comment.outcome, "allowed");
224− assert.deepEqual(fake.posted, [
225− { kind: "request", title: "jira" },
226− { kind: "request", title: "integration:linear:comment" },
227− ]);
228− const bad = await tools.run("request_ability", { needs: "", why: "" });
229− assert.equal(bad.outcome, "refused");
230−});
231−
232−// ── MCP ──────────────────────────────────────────────────────────────────
233−
234−test("an MCP server's tools are offered as <server>__<tool> with the server's schema; reads run alone, writes ask first, Never withholds", async () => {
235− const { tools, fake } = await box({ servers: [SERVER] });
236− const forecast = tools.definitions().find((t) => t.name === "weather__get_forecast");
237− assert.ok(forecast, "offered");
238− assert.deepEqual(forecast!.input_schema, SERVER.tools[0]!.input_schema);
239− assert.match(forecast!.description, /it reads/);
240− const read = await tools.run("weather__get_forecast", { city: "Lisbon" });
241− assert.equal(read.outcome, "allowed");
242− assert.match(read.text, /<untrusted source="weather get_forecast">\nsunny/);
243− const write = await tools.run("weather__set_alert", {});
244− assert.equal(write.outcome, "refused");
245− assert.match(write.text, /"weather: set_alert" is after asking first/);
246− assert.deepEqual(fake.posted, [{ kind: "ability", title: "Call set_alert on weather" }]);
247− const never = await box({ servers: [SERVER], settings: { "mcp:mcp_w:set_alert": { level: "never" } } });
248− assert.ok(!names(never.tools).includes("weather__set_alert"), "not offered at all");
249− assert.equal((await never.tools.run("weather__set_alert", {})).outcome, "refused");
250−});
251−
252−// ── The prompt and what was said ─────────────────────────────────────────
253−
254−test("the prompt's abilities section names each connected system's rows and levels, and what isn't connected", async () => {
255− const { sections } = await box({ connected: ["linear"], settings: { "integration:linear:comment": { level: "never" } }, servers: [SERVER] });
256− const text = abilitiesSection(sections)!;
257− assert.match(text, /- Linear: read issues on your own; import issues on your own only when they asked for it, else it asks first; comment never\./);
258− assert.match(text, /- weather: get_forecast on your own; set_alert asks first \(a card\)\./);
259− assert.match(text, /Not connected to this workspace: Jira, Sentry\./);
260− assert.equal(saidText([null, " ", "a", "b"]), "a\nb");
261−});
262−
263−// ── The definition ───────────────────────────────────────────────────────
264−
265−test("a definition keeps ability settings it knows, within each kind's limit, and never above Ask for a restricted one", () => {
266− const base = applyChanges(null, { handle: "margo", display_name: "Margo", title: "QA", instructions: "Test." }, []);
267− assert.ok(base.ok);
268− const changed = applyChanges(base.value, { abilities: { settings: { "integration:linear:comment": { level: "alone", credentials: "asker" } } } }, []);
269− assert.ok(changed.ok);
270− assert.deepEqual(changed.value.abilities, { settings: { "integration:linear:comment": { level: "alone", credentials: "asker" } }, mcp_servers: [] });
271− const unknown = applyChanges(base.value, { abilities: { settings: { "integration:slack:post": { level: "alone" } } } }, []);
272− assert.ok(!unknown.ok && /no ability called integration:slack:post/.test(unknown.message));
273− const badLevel = applyChanges(base.value, { abilities: { settings: { "integration:linear:read": { level: "loud" as never } } } }, []);
274− assert.ok(!badLevel.ok);
275− const notIntegration = applyChanges(base.value, { abilities: { settings: { "mcp:mcp_w:get_forecast": { level: "ask" } } } }, []);
276− assert.ok(!notIntegration.ok, "no such server on the agent");
277− // With the server, its tools are abilities; removing the server drops their settings.
278− const withServer = applyChanges(base.value, { abilities: { settings: { "mcp:mcp_w:get_forecast": { level: "ask" } } } }, [], { mcp_servers: [SERVER] });
279− assert.ok(withServer.ok);
280− assert.equal(withServer.value.abilities.mcp_servers.length, 1);
281− assert.deepEqual(withServer.value.abilities.settings, { "mcp:mcp_w:get_forecast": { level: "ask" } });
282− const without = applyChanges(withServer.value, {}, [], { mcp_servers: [] });
283− assert.ok(without.ok);
284− assert.deepEqual(without.value.abilities, { settings: {}, mcp_servers: [] });
285− const g1t = applyChanges(base.value, { abilities: { settings: { "g1t:merge": { level: "alone" } } } }, []);
286− assert.ok(!g1t.ok, "g1t's own keep their choices in autonomy");
287−});
288−
289−// ── Cards ────────────────────────────────────────────────────────────────
290−
291−test("the Ask-first, Connect and Request cards say what they are for and offer the right buttons", () => {
292− const waiting = abilityCard({ id: "abr_1", summary: "Comment on ENG-42 in Linear", status: "pending", decided_by: null, result: null, ability: "integration:linear:comment" }, { agent: "Margo", asker: "ana", rule: "Linear: Comment", level: "ask", note: null, body: "Fixed in #412." });
293− assert.equal(waiting.state, "Waiting");
294− assert.deepEqual(waiting.actions?.map((a) => a.id), ["allow", "deny"]);
295− assert.equal(waiting.owner, "agents");
296− assert.equal(waiting.body, "Fixed in #412.");
297− const done = abilityCard({ id: "abr_1", summary: "Comment on ENG-42 in Linear", status: "allowed", decided_by: "ana", result: "Commented on ENG-42.", ability: "integration:linear:comment" }, { agent: "Margo", asker: "ana", rule: "Linear: Comment", level: "ask", note: null, body: null });
298− assert.equal(done.state, "Done");
299− assert.equal(done.actions, undefined);
300− const connect = connectCard({ connector: "Linear", agent: "Margo", ability: "Read issues", asker: "ana", href: "/settings/integrations" });
301− assert.equal(connect.actions?.[0]?.href, "/settings/integrations");
302− assert.equal(connect.owner, null, "a link, no action for the service");
303− const request = requestCard({ agent: { id: "a1", handle: "margo", display_name: "Margo" }, workspace: "acme", connector: { id: "jira", name: "Jira", available: true }, ability: null, why: "Support files bugs there.", status: "open", by: null });
304− assert.deepEqual(request.actions?.map((a) => a.id), ["ask", "open"]);
305− assert.equal(request.ref, "connector:a1:jira");
306− const asked = requestCard({ agent: { id: "a1", handle: "margo", display_name: "Margo" }, workspace: "acme", connector: null, ability: { id: "integration:linear:comment", label: "Linear: Comment" }, why: "x", status: "asked", by: "bo" });
307− assert.equal(asked.state, "Asked");
308− assert.equal(asked.actions?.[0]?.href, "/acme/-/agents/margo/abilities");
309−});
310−
311−// ── The MCP client ───────────────────────────────────────────────────────
312−
313−test("the MCP client lists tools (reads by readOnlyHint) and calls one, over JSON or an event stream", async () => {
314− const seen: { method: string; session: string | null }[] = [];
315− const fetchFn = async (_url: string, init: RequestInit) => {
316− const body = JSON.parse(String(init.body)) as { id?: number; method: string; params?: { name?: string } };
317− seen.push({ method: body.method, session: (init.headers as Record<string, string>)["mcp-session-id"] ?? null });
318− const answer = (result: unknown, sse = false) =>
319− new Response(sse ? `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: body.id, result })}\n\n` : JSON.stringify({ jsonrpc: "2.0", id: body.id, result }), {
320− status: 200,
321− headers: { "content-type": sse ? "text/event-stream" : "application/json", "mcp-session-id": "s1" },
322− });
323− if (body.method === "initialize") return answer({ protocolVersion: "2025-06-18" });
324− if (body.method === "notifications/initialized") return new Response(null, { status: 202 });
325− if (body.method === "tools/list") {
326− return answer(
327− {
328− tools: [
329− { name: "get_forecast", description: "Forecast", inputSchema: { type: "object", properties: { city: { type: "string" } } }, annotations: { readOnlyHint: true } },
330− { name: "set_alert", description: "Alert" },
331− { name: "bad name!" },
332− ],
333− },
334− true,
335− );
336− }
337− if (body.method === "tools/call") return answer({ content: [{ type: "text", text: `sunny in ${JSON.stringify(body.params)}` }] });
338− return new Response("{}", { status: 404 });
339− };
340− const listed = await listMcpTools("https://mcp.example.com/", fetchFn);
341− assert.ok(listed.ok);
342− assert.deepEqual(
343− listed.tools.map((t) => [t.name, t.kind]),
344− [
345− ["get_forecast", "read"],
346− ["set_alert", "write"],
347− ],
348− );
349− assert.deepEqual(listed.tools[1]!.input_schema, { type: "object", properties: {} });
350− assert.equal(seen[2]!.session, "s1", "the session id the server gave rides along");
351− const called = await callMcpTool("https://mcp.example.com/", "get_forecast", { city: "Lisbon" }, fetchFn);
352− assert.ok(called.ok);
353− assert.match(called.text, /sunny in \{"name":"get_forecast","arguments":\{"city":"Lisbon"\}\}/);
354− const down = await listMcpTools("https://mcp.example.com/", async () => {
355− throw new Error("ECONNREFUSED");
356− });
357− assert.ok(!down.ok && /couldn't be reached/.test(down.message));
358− const errored = await callMcpTool("https://mcp.example.com/", "x", {}, async (_u, init) => {
359− const body = JSON.parse(String(init.body)) as { id?: number; method: string };
360− if (body.method === "tools/call") return Response.json({ jsonrpc: "2.0", id: body.id, result: { isError: true, content: [{ type: "text", text: "no such city" }] } });
361− return Response.json({ jsonrpc: "2.0", id: body.id, result: {} });
362− });
363− assert.ok(!errored.ok && errored.message === "no such city");
364−});
+0−438
1−/**
2− * An agent's abilities at work (docs.g1t.sh/guides/agent-abilities/): what
3− * the workspace has connected, the agent's level for each ability
4− * (@g1t/contracts abilities.ts), and the ports that carry a call out once
5− * the tool box's gate allowed it: the integrations service, an MCP server,
6− * and the cards in chat that ask first, offer to connect, or request
7− * something from the owners.
8− *
9− * Asked first: the call is kept in `agent_ability_requests` with its
10− * arguments and a card is posted. Whoever may allow it (the person the
11− * agent acts for, or an owner) presses Allow, the call runs as them, and
12− * the agent hears the result: a session reads it at its next step
13− * (cards.ts). Every refusal names its rule in the transcript, through the
14− * tool's result, and in the audit log, through `refused`.
15− */
16−import { type AbilitySection, type McpServer, type MessageCard, type ServiceBinding, type User, chatClient, identityClient, integrationsClient, newId, notifyClient } from "@g1t/contracts";
17−
18−import { CONNECTORS, connectorPath, connectorView } from "../../../packages/contracts/src/connectors.ts";
19−import { findAbility, resolveAbilities } from "../../../packages/contracts/src/abilities.ts";
20−import { abilityCard, connectCard, requestCard } from "./card-views.ts";
21−import { computerPorts } from "./computer.ts";
22−export { abilitiesSection, saidText } from "./abilities-prompt.ts";
23−import type { Definition } from "./definition.ts";
24−import { callMcpTool } from "./mcp-client.ts";
25−import { type ConnectorRow, type Deps as ConnectorDeps, asServer, headersFor, reaching, sealingKey, stateOf } from "./connectors.ts";
26−import { type Row, isPersonal } from "./store.ts";
27−import type { AbilityPorts, OutsideDone, OutsideItem } from "./tools.ts";
28−
29−export type AbilityEnv = {
30− DB: D1Database;
31− INTEGRATIONS: ServiceBinding;
32− CHAT: ServiceBinding;
33− IDENTITY: ServiceBinding;
34− EVENTS: ServiceBinding;
35− NOTIFY?: ServiceBinding;
36− /** The runner, for a call on the agent's computer allowed from a card (computer.ts). */
37− RUNNER?: ServiceBinding;
38− /** The site's address (https://g1t.sh), for links that leave g1t. */
39− SITE_URL?: string;
40− /** Seals connectors' secrets (connectors.ts); without it, connectors that sign in can't be used. */
41− CONNECTORS_KEY?: string;
42−};
43−
44−/** A call waiting to be allowed, as kept. */
45−export type AbilityRequestRow = {
46− id: string;
47− agent_id: string;
48− workspace_id: string;
49− workspace: string;
50− channel_id: string;
51− message_id: string | null;
52− session_id: string | null;
53− ability: string;
54− tool: string;
55− input: string;
56− summary: string;
57− asked_by: string | null;
58− status: string;
59− decided_by: string | null;
60− decided_at: string | null;
61− result: string | null;
62− created_at: string;
63− updated_at: string;
64−};
65−
66−const iso = () => new Date().toISOString();
67−
68−/** The site's address, without a trailing slash. */
69−export function siteUrl(env: { SITE_URL?: string }): string {
70− return (env.SITE_URL ?? "").trim().replace(/\/+$/, "") || "https://g1t.sh";
71−}
72−
73−/**
74− * The connector ids the workspace has connected, as the integrations
75− * service lists its connections to a member. Empty when it can't say.
76− */
77−export async function connectedConnectors(env: Pick<AbilityEnv, "INTEGRATIONS">, workspace: string, viewer: User): Promise<string[]> {
78− const listed = await integrationsClient(env.INTEGRATIONS)
79− .list(workspace, viewer)
80− .catch(() => null);
81− if (!listed?.ok) return [];
82− const providers = new Set(listed.value.map((connection) => connection.provider));
83− return CONNECTORS.filter((connector) => connector.provider && providers.has(connector.provider)).map((connector) => connector.id);
84−}
85−
86−/** The agent's abilities for a turn: resolved against what is connected. */
87−export async function abilitiesFor(env: Pick<AbilityEnv, "INTEGRATIONS">, input: { agent: Row; definition: Definition; workspace: string; asker: User }): Promise<AbilitySection[]> {
88− const connected = await connectedConnectors(env, input.workspace, input.asker);
89− return resolveAbilities({ connectors: CONNECTORS, abilities: input.definition.abilities, autonomy: input.definition.autonomy, connected, personal: isPersonal(input.agent) });
90−}
91−
92−/** What the connectors' secrets are sealed with, and where sign-ins come back to. */
93−export async function connectorDeps(env: Pick<AbilityEnv, "CONNECTORS_KEY" | "SITE_URL">): Promise<ConnectorDeps> {
94− return { key: await sealingKey(env.CONNECTORS_KEY), site: siteUrl(env) };
95−}
96−
97−/**
98− * The agent's abilities for a turn, with the workspace's connectors that
99− * reach it (docs.g1t.sh/guides/customize/, "Connectors") joined to its own
100− * MCP servers: every connected connector is an MCP source like them, so
101− * levels, Ask-first cards and the audit log treat both alike. `servers`
102− * is what the tool box is given; `connectors` the rows behind them, for
103− * their sign-ins.
104− */
105−export async function abilitiesWithConnectors(
106− env: Pick<AbilityEnv, "INTEGRATIONS" | "DB">,
107− input: { agent: Row; definition: Definition; workspace: string; asker: User; teams: readonly string[] },
108−): Promise<{ sections: AbilitySection[]; servers: McpServer[]; connectors: Map<string, ConnectorRow> }> {
109− const own = input.definition.abilities.mcp_servers ?? [];
110− const reach = await reaching(env.DB, input.agent.workspace_id, input.agent.id, input.teams).catch((error: unknown) => {
111− console.error("agents: connectors not read", input.agent.id, String(error));
112− return [] as Awaited<ReturnType<typeof reaching>>;
113− });
114− const rows = new Map<string, ConnectorRow>();
115− const servers: McpServer[] = [...own];
116− for (const { row } of reach) {
117− if (stateOf(row) !== "connected") continue;
118− rows.set(row.id, row);
119− servers.push(asServer(row));
120− }
121− const connected = await connectedConnectors(env, input.workspace, input.asker);
122− const sections = resolveAbilities({ connectors: CONNECTORS, abilities: { ...input.definition.abilities, mcp_servers: servers }, autonomy: input.definition.autonomy, connected, personal: isPersonal(input.agent) });
123− return { sections, servers, connectors: rows };
124−}
125−
126−/** Where a request's card and a session's wait point: the Abilities tab. */
127−export function abilitiesPath(workspace: string, handle: string): string {
128− return `/${workspace}/-/agents/${handle}/abilities`;
129−}
130−
131−const item = (c: { provider: string; key: string; title: string; url: string; status: string | null; body: string }): OutsideItem => ({ provider: c.provider, key: c.key, title: c.title, url: c.url, status: c.status, body: c.body });
132−
133−const outcome = <T, U>(result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } }, map: (value: T) => U): OutsideDone<U> =>
134− result.ok ? { ok: true, value: map(result.value) } : { ok: false, code: result.error.code, message: result.error.message };
135−
136−/**
137− * The ports for one turn. `postCard` posts where the agent is working (a
138− * reply's conversation, a session's thread) and gives the message id.
139− */
140−export function abilityPorts(
141− env: AbilityEnv,
142− input: {
143− agent: Row;
144− workspace: string;
145− channel_id: string;
146− session: { id: string; title: string } | null;
147− asker: { id: string | null; username: string | null };
148− postCard: (card: MessageCard) => Promise<string | null>;
149− /** The workspace's connectors behind the agent's MCP servers, by server id (abilitiesWithConnectors). */
150− connectors?: Map<string, ConnectorRow>;
151− },
152−): AbilityPorts {
153− const integrations = integrationsClient(env.INTEGRATIONS);
154− let deps: Promise<ConnectorDeps> | null = null;
155− const { agent, workspace } = input;
156− const link = `${siteUrl(env)}/${workspace}/-/chat/${input.channel_id}`;
157− return {
158− lookup: async (asker, reference) => outcome(await integrations.resolve(workspace, asker, reference), item),
159− import: async (asker, repo, reference) =>
160− outcome(await integrations.import(asker, { namespace: repo.namespace, name: repo.name }, reference, false), (v) => ({ number: v.number, item: item(v.item), created: v.created })),
161− act: async (asker, reference, action, text) => {
162− const signed = `${text}\n\n— ${agent.display_name} (@${agent.handle}), a g1t agent, for @${asker.username}.`;
163− const done = action === "resolve" ? await integrations.close(asker, workspace, reference, signed, link) : await integrations.comment(asker, workspace, reference, signed, link);
164− return outcome(done, item);
165− },
166− // Personal connections to Linear, Jira and Sentry aren't available yet (connectors.ts says so), so nobody has one.
167− askerConnected: async () => false,
168− mcp: async (server, tool, args) => {
169− let headers: Record<string, string> = {};
170− const row = input.connectors?.get(server.id);
171− if (row) {
172− deps ??= connectorDeps(env);
173− const signed = await headersFor(env.DB, await deps, row);
174− if (!signed) return { ok: false, code: "error", message: `${row.title} needs to be signed in again by an owner (Customize → Connectors).` };
175− headers = signed;
176− }
177− const done = await callMcpTool(server.url, tool.name, args, undefined, headers);
178− return done.ok ? { ok: true, value: done.text } : { ok: false, code: "error", message: done.message };
179− },
180− async askFirst({ ability, source, tool, args, summary, note }) {
181− const id = newId("abr");
182− const now = iso();
183− const row: AbilityRequestRow = {
184− id,
185− agent_id: agent.id,
186− workspace_id: agent.workspace_id,
187− workspace,
188− channel_id: input.channel_id,
189− message_id: null,
190− session_id: input.session?.id ?? null,
191− ability: ability.id,
192− tool,
193− input: JSON.stringify(args).slice(0, 20_000),
194− summary: summary.slice(0, 300),
195− asked_by: input.asker.id,
196− status: "pending",
197− decided_by: null,
198− decided_at: null,
199− result: null,
200− created_at: now,
201− updated_at: now,
202− };
203− await env.DB.prepare(
204− `INSERT INTO agent_ability_requests (id, agent_id, workspace_id, workspace, channel_id, session_id, ability, tool, input, summary, asked_by, status, created_at, updated_at)
205− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
206− )
207− .bind(id, row.agent_id, row.workspace_id, row.workspace, row.channel_id, row.session_id, row.ability, row.tool, row.input, row.summary, row.asked_by, now, now)
208− .run();
209− const messageId = await input.postCard(abilityCard(row, { agent: agent.display_name, asker: input.asker.username, rule: `${source.name}: ${ability.label}`, level: ability.level, note, body: bodyOf(args) }));
210− if (!messageId) {
211− await env.DB.prepare("UPDATE agent_ability_requests SET status = 'failed', result = ?, updated_at = ? WHERE id = ?").bind("The card couldn't be posted.", iso(), id).run();
212− return null;
213− }
214− await env.DB.prepare("UPDATE agent_ability_requests SET message_id = ? WHERE id = ?").bind(messageId, id).run();
215− return id;
216− },
217− async connect(source, ability) {
218− const connector = CONNECTORS.find((c) => c.id === source.id);
219− const view = connector ? connectorView(connector, "personal") : null;
220− const href = view?.href ? connectorPath(view.href, workspace) : "/settings/integrations";
221− const messageId = await input.postCard(connectCard({ connector: source.name, agent: agent.display_name, ability: ability.label, asker: input.asker.username, href }));
222− return messageId !== null;
223− },
224− async request({ connector, ability, source, why }) {
225− const found = connector ? CONNECTORS.find((c) => c.id === connector) : null;
226− if (!ability && !found) return false;
227− const card = requestCard({
228− agent: { id: agent.id, handle: agent.handle, display_name: agent.display_name },
229− workspace,
230− connector: found ? { id: found.id, name: found.name, available: found.status === "available" && found.scopes.includes("workspace") } : null,
231− ability: ability && source ? { id: ability.id, label: `${source.name}: ${ability.label}` } : null,
232− why,
233− status: "open",
234− by: null,
235− });
236− return (await input.postCard(card)) !== null;
237− },
238− refused({ ability, source, rule, call }) {
239− recordRefusal(env, { agent, workspace, asker: input.asker.username, rule, message: `Refused "${call}": ${source.name}: ${ability.label} is ${rule.split("=")[1] ?? ability.level}.` });
240− },
241− };
242−}
243−
244−/** A card's preview of what a call would write or run: the text of a comment or note, or the command. */
245−function bodyOf(args: Record<string, unknown>): string | null {
246− const text = typeof args.text === "string" ? args.text.trim() : typeof args.command === "string" ? `$ ${args.command.trim()}` : "";
247− return text ? text.slice(0, 900) : null;
248−}
249−
250−/**
251− * A refusal in the workspace's audit log, by the agent, naming the rule
252− * (`integration:linear:comment=never`). Never fails the turn.
253− */
254−export function recordRefusal(env: Pick<AbilityEnv, "EVENTS">, input: { agent: Row; workspace: string; asker: string | null; rule: string; message: string }): void {
255− const entry = {
256− actorKind: "agent",
257− actor: input.agent.handle,
258− actorId: input.agent.id,
259− agent: input.agent.handle,
260− onBehalfOf: input.asker,
261− runId: null,
262− runKind: null,
263− credentialId: null,
264− action: "ability_refused",
265− // The audit log knows the surfaces people use; an agent acts through the site on their behalf.
266− surface: "web",
267− workspace: input.workspace.toLowerCase(),
268− repo: null,
269− number: null,
270− gitRef: null,
271− path: `agents/${input.agent.handle}`,
272− outcome: "denied",
273− rule: input.rule,
274− result: "refused",
275− message: input.message,
276− requestId: `req_${crypto.randomUUID()}`,
277− };
278− env.EVENTS.fetch("https://service/rpc/audit_record", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ entries: [entry] }) })
279− .then((response) => {
280− if (!response.ok) throw new Error(`status ${response.status}`);
281− })
282− .catch((error: unknown) => console.error("agents: a refusal was not written to the audit log", String(error)));
283−}
284−
285−/** An allowed or denied call in the audit log, by the person who decided. */
286−export function recordDecision(env: Pick<AbilityEnv, "EVENTS">, input: { by: User; agent: Row; workspace: string; request: AbilityRequestRow; allowed: boolean }): void {
287− const entry = {
288− actorKind: "person",
289− actor: input.by.username,
290− actorId: input.by.id,
291− agent: input.agent.handle,
292− onBehalfOf: null,
293− runId: null,
294− runKind: null,
295− credentialId: null,
296− action: input.allowed ? "ability_allowed" : "ability_denied",
297− surface: "web",
298− workspace: input.workspace.toLowerCase(),
299− repo: null,
300− number: null,
301− gitRef: null,
302− path: `agents/${input.agent.handle}`,
303− outcome: "allowed",
304− rule: `${input.request.ability}=ask`,
305− result: "ok",
306− message: `${input.allowed ? "Allowed" : "Denied"} @${input.agent.handle}: ${input.request.summary}`,
307− requestId: `req_${crypto.randomUUID()}`,
308− };
309− env.EVENTS.fetch("https://service/rpc/audit_record", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ entries: [entry] }) })
310− .then((response) => {
311− if (!response.ok) throw new Error(`status ${response.status}`);
312− })
313− .catch((error: unknown) => console.error("agents: a decision was not written to the audit log", String(error)));
314−}
315−
316−/** Pending requests of a session: what it waits on. */
317−export async function pendingRequests(db: D1Database, sessionId: string): Promise<AbilityRequestRow[]> {
318− const { results } = await db.prepare("SELECT * FROM agent_ability_requests WHERE session_id = ? AND status = 'pending' ORDER BY created_at").bind(sessionId).all<AbilityRequestRow>();
319− return results;
320−}
321−
322−/**
323− * Runs an allowed call as `by`, the person who allowed it, with the
324− * agent's abilities as they are now (a server or a connection may have
325− * gone since). What the agent is told.
326− */
327−export async function runAllowed(env: AbilityEnv, request: AbilityRequestRow, agent: Row, definition: Definition, by: User): Promise<{ ok: boolean; message: string }> {
328− const teams = await identityClient(env.IDENTITY)
329− .agentTeams(request.workspace, agent.id)
330− .then((t) => t.map((x) => x.slug))
331− .catch(() => [] as string[]);
332− const { sections, servers, connectors } = await abilitiesWithConnectors(env, { agent, definition, workspace: request.workspace, asker: by, teams });
333− const found = findAbility(sections, request.ability);
334− if (!found) return { ok: false, message: `The ability ${request.ability} is no longer there.` };
335− if (!found.source.connected) return { ok: false, message: `${found.source.name} is no longer connected.` };
336− if (found.ability.level === "never") return { ok: false, message: `${found.source.name}: ${found.ability.label} is Never now.` };
337− let args: Record<string, unknown> = {};
338− try {
339− args = JSON.parse(request.input) as Record<string, unknown>;
340− } catch {
341− return { ok: false, message: "The call's arguments couldn't be read." };
342− }
343− const ports = abilityPorts(env, { agent, workspace: request.workspace, channel_id: request.channel_id, session: null, asker: { id: by.id, username: by.username }, postCard: async () => null, connectors });
344− const reference = String(args.reference ?? "").trim();
345− try {
346− switch (request.tool) {
347− case "lookup_outside": {
348− const done = await ports.lookup(by, reference);
349− return done.ok ? { ok: true, message: `${done.value.key}: ${done.value.title}${done.value.status ? ` [${done.value.status}]` : ""}\n${done.value.url}\n\n${done.value.body}`.slice(0, 20_000) } : { ok: false, message: done.message };
350− }
351− case "import_outside": {
352− const repo = String(args.repo ?? "").trim().toLowerCase();
353− const [namespace, name] = repo.includes("/") ? repo.split("/") : [request.workspace, repo];
354− if (!namespace || !name) return { ok: false, message: "The call named no repository." };
355− const done = await ports.import(by, { id: "", namespace, name, isPrivate: true, defaultBranch: "main" }, reference);
356− return done.ok ? { ok: true, message: `${done.value.created ? "Opened" : "Already imported as"} ${namespace}/${name}#${done.value.number} from ${done.value.item.key}.` } : { ok: false, message: done.message };
357− }
358− case "act_outside": {
359− const action = args.action === "resolve" ? "resolve" : "comment";
360− const done = await ports.act(by, reference, action, String(args.text ?? "").trim().slice(0, 8000));
361− return done.ok ? { ok: true, message: `${action === "resolve" ? "Resolved" : "Commented on"} ${done.value.key} (${done.value.url}).` } : { ok: false, message: done.message };
362− }
363− case "run_command":
364− case "computer_read_file":
365− case "computer_write_file": {
366− // On the agent's own computer, in the session that asked (or a directory of its own for a reply's card).
367− const computer = computerPorts(env, { agent, workspace: request.workspace, session: { id: request.session_id ?? `card-${request.id}` }, asker: { username: by.username }, onCommand: () => undefined });
368− if (!computer) return { ok: false, message: "Agents' computers aren't available on this installation." };
369− const path = String(args.path ?? "").trim();
370− if (request.tool === "run_command") {
371− const command = String(args.command ?? "").trim();
372− if (!command) return { ok: false, message: "The call named no command." };
373− const timeout = Number.isFinite(Number(args.timeout_seconds)) && Number(args.timeout_seconds) > 0 ? Math.min(1800, Math.floor(Number(args.timeout_seconds))) : null;
374− const ran = await computer.exec(command, typeof args.cwd === "string" && args.cwd.trim() ? args.cwd.trim() : null, timeout);
375− if (!ran.ok) return { ok: false, message: ran.message };
376− const how = [`exit ${ran.value.exit_code}`, `${(ran.value.duration_ms / 1000).toFixed(1)} s`, ran.value.timed_out ? "killed at the timeout" : null, ran.value.truncated ? "output cut" : null].filter(Boolean).join(", ");
377− return { ok: ran.value.exit_code === 0, message: `$ ${ran.value.cmd}\n(${how}; in ${ran.value.cwd})\n${ran.value.output || "(no output)"}`.slice(0, 20_000) };
378− }
379− if (!path) return { ok: false, message: "The call named no path." };
380− if (request.tool === "computer_read_file") {
381− const read = await computer.readFile(path);
382− return read.ok ? { ok: true, message: read.value.text.slice(0, 20_000) } : { ok: false, message: read.message };
383− }
384− const wrote = await computer.writeFile(path, String(args.text ?? ""));
385− return wrote.ok ? { ok: true, message: `Wrote ${wrote.value.bytes} bytes to ${wrote.value.path}.` } : { ok: false, message: wrote.message };
386− }
387− default: {
388− // An MCP tool: `<server>__<tool>`.
389− const [, serverId, ...rest] = request.ability.split(":");
390− const server = servers.find((s: McpServer) => s.id === serverId);
391− const tool = server?.tools.find((t) => t.name === rest.join(":"));
392− if (!server || !tool) return { ok: false, message: "That MCP tool is no longer there." };
393− const done = await ports.mcp(server, tool, args);
394− return done.ok ? { ok: true, message: done.value.slice(0, 20_000) } : { ok: false, message: done.message };
395− }
396− }
397− } catch (error) {
398− return { ok: false, message: `It failed: ${String(error).slice(0, 200)}` };
399− }
400−}
401−
402−/** Tells the owners (and whoever asked, for a request on their behalf) that a Request card was pressed. */
403−export async function tellOwnersOfRequest(env: AbilityEnv, input: { workspace: string; by: User; title: string; body: string; href: string; id: string }): Promise<void> {
404− if (!env.NOTIFY) return;
405− const members = await identityClient(env.IDENTITY)
406− .listMembers(input.workspace, input.by)
407− .catch(() => null);
408− if (!members?.ok) return;
409− const notify = notifyClient(env.NOTIFY);
410− const owners = members.value.filter((member) => member.role === "owner").slice(0, 20);
411− await Promise.all(
412− owners.map((owner) =>
413− notify
414− .notify(
415− { username: owner.username },
416− {
417− id: `ability-request:${input.id}:${owner.username}`,
418− kind: "approval",
419− workspace: input.workspace,
420− title: input.title,
421− body: input.body,
422− href: input.href,
423− actor: { kind: "user", id: input.by.id, name: input.by.username, avatar: input.by.avatar ?? null, avatar_seed: null },
424− created_at: iso(),
425− },
426− )
427− .catch(() => undefined),
428− ),
429− );
430−}
431−
432−/** Posts a card in a conversation as the agent; its message id, or null. */
433−export async function postCardAsAgent(env: Pick<AbilityEnv, "CHAT">, workspace: string, channelId: string, agentId: string, card: MessageCard, threadRoot: string | null, askedBy: string | null): Promise<string | null> {
434− const posted = await chatClient(env.CHAT)
435− .postAsAgent(workspace, channelId, agentId, { body: "", card, thread_root: threadRoot, asked_by: askedBy })
436− .catch(() => null);
437− return posted?.ok ? posted.value.id : null;
438−}
+0−45
1−import assert from "node:assert/strict";
2−import { test } from "node:test";
3−
4−import { canArchive, canChange, canSeeAgent, creatableScope, personalRefusal } from "./access.ts";
5−
6−const owner = { id: "u0", username: "olga", kind: "user" as const, workspaces: [{ slug: "acme", role: "owner" as const }] };
7−const ana = { id: "u1", username: "ana", kind: "user" as const, workspaces: [{ slug: "acme", role: "member" as const }] };
8−const bob = { id: "u2", username: "bob", kind: "user" as const, workspaces: [{ slug: "acme", role: "member" as const }] };
9−const token = { id: "w1", username: "acme", kind: "workspace" as const, workspaces: [] };
10−
11−const anasAgent = { scope: "personal", owner_id: "u1" };
12−const shared = { scope: "workspace", owner_id: null };
13−
14−test("members create personal agents while the workspace lets them; owners create either", () => {
15− assert.deepEqual(creatableScope(ana, "acme", undefined, true), { ok: true, scope: "personal" });
16− assert.equal(creatableScope(ana, "acme", undefined, false).ok, false, "owners turned it off");
17− assert.equal(creatableScope(ana, "acme", "workspace", true).ok, false, "workspace agents are the owners'");
18− assert.deepEqual(creatableScope(owner, "acme", undefined, false), { ok: true, scope: "workspace" });
19− assert.deepEqual(creatableScope(owner, "acme", "personal", false), { ok: true, scope: "personal" }, "an owner may keep one for themselves");
20− assert.equal(creatableScope(token, "acme", "personal", true).ok, false, "a token is nobody's person");
21− assert.equal(creatableScope(ana, "other", undefined, true).ok, false);
22−});
23−
24−test("a personal agent is seen by its member and owners, and changed only by its member", () => {
25− assert.equal(canSeeAgent(ana, "acme", anasAgent), true);
26− assert.equal(canSeeAgent(owner, "acme", anasAgent), true);
27− assert.equal(canSeeAgent(bob, "acme", anasAgent), false);
28− assert.equal(canSeeAgent(bob, "acme", shared), true);
29− assert.equal(canChange(ana, "acme", anasAgent), true);
30− assert.equal(canChange(owner, "acme", anasAgent), false, "owners promote or archive it, not rewrite it");
31− assert.equal(canChange(ana, "acme", shared), false);
32− assert.equal(canChange(owner, "acme", shared), true);
33− assert.equal(canArchive(owner, "acme", anasAgent), true);
34− assert.equal(canArchive(ana, "acme", anasAgent), true);
35− assert.equal(canArchive(bob, "acme", anasAgent), false);
36−});
37−
38−test("a personal agent answers only its member, in the DM of the two of them", () => {
39− assert.equal(personalRefusal(anasAgent, { asked_by: "u1", channel_kind: "dm", members: 2 }), null);
40− assert.equal(personalRefusal(anasAgent, { asked_by: "u1", channel_kind: "dm", members: null }), null);
41− assert.match(personalRefusal(anasAgent, { asked_by: "u2", channel_kind: "dm", members: 2 }) ?? "", /personal agent/);
42− assert.match(personalRefusal(anasAgent, { asked_by: "u1", channel_kind: "dm", members: 3 }) ?? "", /direct message/);
43− assert.match(personalRefusal(anasAgent, { asked_by: "u1", channel_kind: "channel", members: 5 }) ?? "", /direct message/);
44− assert.equal(personalRefusal(shared, { asked_by: "u2", channel_kind: "channel", members: 9 }), null);
45−});
+0−98
1−/**
2− * Who may see and who may change a workspace's agents. Pure, so it is
3− * tested on its own.
4− *
5− * Any member sees the workspace's agents: agents are members too, and
6− * people need to know who they can talk to. Only owners create, change or
7− * archive them, since such an agent spends the workspace's money and acts
8− * in its name. A workspace's own access token, acting as the workspace,
9− * counts as an owner, as it does for the workspace's other settings.
10− *
11− * Personal agents (docs.g1t.sh/guides/agents/, "Personal agents") are a
12− * member's own: any member may create one while the workspace lets members
13− * (`members_create_agents`, on unless an owner turns it off). Only its
14− * member sees it, changes it and talks to it, in their direct message with
15− * it; owners see it too, archive it, and promote it to a workspace agent.
16− */
17−import type { WorkspaceAgentScope, User } from "@g1t/contracts";
18−
19−type Viewer = Pick<User, "kind" | "username" | "workspaces" | "verified"> & { id?: string } | null | undefined;
20−
21−/** An agent as these rules read it. */
22−export type Owned = { scope?: string | null; owner_id?: string | null; builtin?: number | boolean };
23−
24−export function canSee(viewer: Viewer, workspace: string): boolean {
25− const slug = workspace.toLowerCase();
26− if (!viewer) return false;
27− if (viewer.kind === "workspace") return viewer.username.toLowerCase() === slug;
28− return !!viewer.workspaces?.some((m) => m.slug.toLowerCase() === slug);
29−}
30−
31−export function canManage(viewer: Viewer, workspace: string): boolean {
32− const slug = workspace.toLowerCase();
33− if (!viewer) return false;
34− if (viewer.kind === "workspace") return viewer.username.toLowerCase() === slug;
35− return !!viewer.workspaces?.some((m) => m.slug.toLowerCase() === slug && m.role === "owner");
36−}
37−
38−export const MANAGE_REFUSAL = "Only the workspace's owners can create, change or archive its agents.";
39−export const PERSONAL_OFF_REFUSAL = "This workspace's owners have turned off personal agents. Ask an owner to make the agent for the workspace.";
40−export const NOT_YOURS_REFUSAL = "Only the person whose personal agent this is can change it.";
41−
42−const personal = (agent: Owned) => agent.scope === "personal";
43−
44−/** Whether `viewer` is the member a personal agent belongs to. */
45−export function isOwnerOf(viewer: Viewer, agent: Owned): boolean {
46− return !!viewer && (viewer.kind ?? "user") === "user" && personal(agent) && !!agent.owner_id && viewer.id === agent.owner_id;
47−}
48−
49−/** Whether the viewer sees this agent at all: a personal one only its member and the owners. */
50−export function canSeeAgent(viewer: Viewer, workspace: string, agent: Owned): boolean {
51− if (!canSee(viewer, workspace)) return false;
52− return !personal(agent) || isOwnerOf(viewer, agent) || canManage(viewer, workspace);
53−}
54−
55−/** Whether the viewer may change the agent's definition: owners a workspace agent, its member a personal one. */
56−export function canChange(viewer: Viewer, workspace: string, agent: Owned): boolean {
57− return personal(agent) ? isOwnerOf(viewer, agent) : canManage(viewer, workspace);
58−}
59−
60−/** Whether the viewer may archive it: owners any agent, a member their own personal one. */
61−export function canArchive(viewer: Viewer, workspace: string, agent: Owned): boolean {
62− return canManage(viewer, workspace) || isOwnerOf(viewer, agent);
63−}
64−
65−/**
66− * The scope a new agent gets, or why the viewer may not create one:
67− * owners make workspace agents unless they ask for a personal one; anyone
68− * else makes personal agents, while the workspace lets them.
69− */
70−export function creatableScope(
71− viewer: Viewer,
72− workspace: string,
73− asked: unknown,
74− membersMayCreate: boolean,
75−): { ok: true; scope: WorkspaceAgentScope } | { ok: false; message: string } {
76− if (!canSee(viewer, workspace)) return { ok: false, message: "There is no such workspace." };
77− const owner = canManage(viewer, workspace);
78− const scope: WorkspaceAgentScope = asked === "personal" || asked === "workspace" ? asked : owner ? "workspace" : "personal";
79− if (scope === "workspace") return owner ? { ok: true, scope } : { ok: false, message: MANAGE_REFUSAL };
80− // A personal agent belongs to a person: never to a workspace's token or another agent.
81− if ((viewer!.kind ?? "user") !== "user" || !viewer!.id) return { ok: false, message: "A personal agent belongs to a person: create it signed in as yourself." };
82− if (!owner && !membersMayCreate) return { ok: false, message: PERSONAL_OFF_REFUSAL };
83− return { ok: true, scope };
84−}
85−
86−/**
87− * Whether a personal agent may answer here: only its member, only in the
88− * direct message of the two of them. `members` is how many people and
89− * agents are in the conversation, when known. Null: it may; otherwise why not.
90− */
91−export function personalRefusal(agent: Owned, place: { asked_by: string; channel_kind: "channel" | "dm"; members?: number | null }): string | null {
92− if (!personal(agent)) return null;
93− if (place.asked_by !== agent.owner_id) return "I'm someone's personal agent, so only they can talk to me.";
94− if (place.channel_kind !== "dm" || (place.members != null && place.members > 2)) {
95− return "I'm a personal agent: I answer only in my direct message with the person I belong to.";
96− }
97− return null;
98−}
+0−182
Loading the diff…
+0−160
Loading the diff…
+0−175
Loading the diff…
+0−90
Loading the diff…
+0−153
Loading the diff…
+0−109
Loading the diff…
+0−165
Loading the diff…
+0−391
Loading the diff…
+0−30
Loading the diff…
+0−184
Loading the diff…
+0−40
Loading the diff…
+0−315
Loading the diff…
+0−232
Loading the diff…
+0−163
Loading the diff…
+0−220
Loading the diff…
+0−669
Loading the diff…
+0−247
Loading the diff…
+0−499
Loading the diff…
+0−307
Loading the diff…
+0−218
Loading the diff…
+0−460
Loading the diff…
+0−131
Loading the diff…
+0−73
Loading the diff…
+0−205
Loading the diff…
+0−196
Loading the diff…
+0−144
Loading the diff…
+0−36
Loading the diff…
+0−33
Loading the diff…
+0−75
Loading the diff…
+0−86
Loading the diff…
+0−39
Loading the diff…
+0−1168
A large diff (1,168 lines) is not shown until you ask for it.
+0−148
Loading the diff…
+0−231
Loading the diff…
+0−328
Loading the diff…
+0−146
Loading the diff…
+0−180
Loading the diff…
+0−384
Loading the diff…
+0−7
Loading the diff…
+0−300
Loading the diff…
+0−161
Loading the diff…
+0−168
Loading the diff…
+0−397
Loading the diff…
+0−91
Loading the diff…
+0−514
Loading the diff…
+0−125
Loading the diff…
+0−302
Loading the diff…
+0−270
Loading the diff…
+0−400
Loading the diff…
+0−50
Loading the diff…
+0−59
Loading the diff…
+0−77
Loading the diff…
+0−385
Loading the diff…
+0−585
Loading the diff…
+0−203
Loading the diff…
+0−61
Loading the diff…
+0−176
Loading the diff…
+0−85
Loading the diff…
+0−477
Loading the diff…
+0−1170
A large diff (1,170 lines) is not shown until you ask for it.
+0−85
Loading the diff…
+0−458
Loading the diff…
+0−969
Loading the diff…
+0−133
Loading the diff…
+0−137
Loading the diff…
+0−269
Loading the diff…
+0−231
Loading the diff…
+0−253
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…