From 503a3492027dcfd47367add63fdfd2b8bde1c31f Mon Sep 17 00:00:00 2001 From: Steve James Date: Tue, 13 Jan 2026 13:11:32 +0100 Subject: [PATCH 1/5] add custom agent id support and url-encode agency ids --- lib/client/index.ts | 4 +++- lib/runtime/agency.ts | 48 ++++++++++++++++++++++++++++++++++++++++--- lib/runtime/worker.ts | 4 +++- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/lib/client/index.ts b/lib/client/index.ts index df24b6b..e3b38e4 100644 --- a/lib/client/index.ts +++ b/lib/client/index.ts @@ -310,6 +310,8 @@ export interface SpawnAgentRequest { agentType: string; relatedAgentId?: string; input?: Record; + /** Optional custom ID for the agent. If an agent with this ID exists, it will be resumed instead of created. */ + id?: string; } export interface InvokeRequest { @@ -497,7 +499,7 @@ export class AgencyClient { ) {} private get path(): string { - return `${this.baseUrl}/agency/${this.agencyId}`; + return `${this.baseUrl}/agency/${encodeURIComponent(this.agencyId)}`; } private async request( diff --git a/lib/runtime/agency.ts b/lib/runtime/agency.ts index 7a313ad..0faf905 100644 --- a/lib/runtime/agency.ts +++ b/lib/runtime/agency.ts @@ -731,9 +731,10 @@ export class Agency extends Agent { requestContext?: ThreadRequestContext; input?: Record; relatedAgentId?: string; + id?: string; }; - return this.spawnAgent(body.agentType, body.requestContext, body.input, body.relatedAgentId); + return this.spawnAgent(body.agentType, body.requestContext, body.input, body.relatedAgentId, body.id); } private async handleDeleteAgent(agentId: string): Promise { @@ -868,10 +869,51 @@ export class Agency extends Agent { agentType: string, requestContext?: ThreadRequestContext, input?: Record, - relatedAgentId?: string + relatedAgentId?: string, + providedId?: string ): Promise { - const id = crypto.randomUUID(); + const id = providedId || crypto.randomUUID(); const createdAt = Date.now(); + + // Check if agent with this ID already exists + if (providedId) { + const existing = this.sql<{ id: string }>` + SELECT id FROM agents WHERE id = ${providedId} + `; + if (existing.length > 0) { + // Agent exists - invoke it instead of creating + const stub = await getAgentByName(this.exports.HubAgent, providedId); + + if (input) { + const userMessage = + typeof input.message === "string" + ? input.message + : JSON.stringify(input); + + await stub.fetch( + new Request("http://do/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: userMessage }], + }), + }) + ); + } + + // Return existing agent info + const meta = this.sql<{ metadata: string; created_at: number }>` + SELECT metadata, created_at FROM agents WHERE id = ${providedId} + `[0]; + + return Response.json({ + id: providedId, + createdAt: new Date(meta.created_at).toISOString(), + agentType, + resumed: true, + }, { status: 200 }); + } + } const meta = { request: requestContext, diff --git a/lib/runtime/worker.ts b/lib/runtime/worker.ts index d500aa2..631ce67 100644 --- a/lib/runtime/worker.ts +++ b/lib/runtime/worker.ts @@ -156,7 +156,9 @@ const createAgency = async (req: IRequest, { env }: RequestContext) => { async function getAgencyStub(agencyId: string, ctx: CfCtx): Promise> { - return getAgentByName(ctx.exports.Agency, agencyId); + // Decode in case the agency ID contains slashes (e.g., "owner/repo") + const decodedId = decodeURIComponent(agencyId); + return getAgentByName(ctx.exports.Agency, decodedId); } const deleteAgency = async (req: IRequest, { ctx }: RequestContext) => { From 7a9cc25ed29950459ce6e5b80f0e417ecb114633 Mon Sep 17 00:00:00 2001 From: Steve James Date: Tue, 13 Jan 2026 13:14:03 +0100 Subject: [PATCH 2/5] fix agency registration and url-encode agency ids in R2 paths - agencies now auto-register in R2 on first access (ensureRegisteredInR2) - agency IDs with slashes (e.g., owner/repo) are URL-encoded in R2 paths - listAgencies properly decodes agency names - createAgency now allows slashes in names --- lib/runtime/agency.ts | 49 ++++++++++++++++++++++++++++++++++++++----- lib/runtime/worker.ts | 25 ++++++++++++++-------- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/lib/runtime/agency.ts b/lib/runtime/agency.ts index 0faf905..09bd0d5 100644 --- a/lib/runtime/agency.ts +++ b/lib/runtime/agency.ts @@ -155,6 +155,39 @@ export class Agency extends Agent { this._cachedAgencyName = stored; } else { this.persistName(this.name); + // Register this agency in R2 so it appears in listAgencies + this.ensureRegisteredInR2(this.name); + } + } + + /** + * Ensure this agency is registered in R2 with a .agency.json file. + * This allows the agency to appear in listAgencies even if it was + * created implicitly (via direct DO access rather than POST /agencies). + */ + private async ensureRegisteredInR2(agencyId: string): Promise { + try { + const bucket = this.env.FS; + if (!bucket) return; + + // Encode the agency ID for R2 path (handles slashes in owner/repo) + const encodedId = encodeURIComponent(agencyId); + const metaPath = `${encodedId}/.agency.json`; + + // Check if already registered + const existing = await bucket.head(metaPath); + if (existing) return; + + // Create the agency metadata file + const meta = { + id: agencyId, + name: agencyId, + createdAt: new Date().toISOString(), + }; + await bucket.put(metaPath, JSON.stringify(meta)); + } catch (e) { + // Log but don't fail - registration is best-effort + console.error("[Agency] Failed to register in R2:", e); } } @@ -242,6 +275,11 @@ export class Agency extends Agent { ); } + /** Get the URL-encoded agency name for use in R2 paths */ + private get encodedAgencyName(): string { + return encodeURIComponent(this.agencyName); + } + private persistName(name: string): void { if (this._cachedAgencyName === name) return; this._cachedAgencyName = name; @@ -1282,7 +1320,7 @@ export class Agency extends Agent { const bucket = this.env.FS; if (bucket) { - await this.deletePrefix(bucket, `${this.agencyName}/`); + await this.deletePrefix(bucket, `${this.encodedAgencyName}/`); } await this.destroy(); @@ -1319,8 +1357,8 @@ export class Agency extends Agent { const bucket = this.env.FS; if (bucket) { - await this.deletePrefix(bucket, `${this.agencyName}/agents/${agentId}/`); - await bucket.delete(`${this.agencyName}/agents/${agentId}`).catch(() => {}); + await this.deletePrefix(bucket, `${this.encodedAgencyName}/agents/${agentId}/`); + await bucket.delete(`${this.encodedAgencyName}/agents/${agentId}`).catch(() => {}); } this.sql` @@ -1406,8 +1444,9 @@ export class Agency extends Agent { }); } - // Build R2 key: /{agencyId}/{fsPath} - const r2Prefix = this.agencyName + "/"; + // Build R2 key: /{encodedAgencyId}/{fsPath} + // Use encoded agency name to handle slashes in agency IDs (e.g., owner/repo) + const r2Prefix = this.encodedAgencyName + "/"; const r2Key = r2Prefix + fsPath; switch (req.method) { diff --git a/lib/runtime/worker.ts b/lib/runtime/worker.ts index 631ce67..7753781 100644 --- a/lib/runtime/worker.ts +++ b/lib/runtime/worker.ts @@ -105,20 +105,24 @@ const getPlugins = (req: IRequest, { opts }: RequestContext) => { const listAgencies = async (req: IRequest, { env }: RequestContext) => { const agencies = []; + // List all .agency.json files - they're stored with encoded agency IDs as prefixes const list = await env.FS.list({ delimiter: "/" }); for (const prefix of list.delimitedPrefixes) { - const agencyName = prefix.replace(/\/$/, ""); - const metaObj = await env.FS.get(`${agencyName}/.agency.json`); + const encodedName = prefix.replace(/\/$/, ""); + const metaObj = await env.FS.get(`${encodedName}/.agency.json`); if (metaObj) { try { const meta = await metaObj.json(); agencies.push(meta); } catch { - // Corrupted or empty .agency.json - use defaults - agencies.push({ id: agencyName, name: agencyName }); + // Corrupted or empty .agency.json - decode the name for display + const decodedName = decodeURIComponent(encodedName); + agencies.push({ id: decodedName, name: decodedName }); } } else { - agencies.push({ id: agencyName, name: agencyName }); + // No .agency.json but prefix exists - decode the name for display + const decodedName = decodeURIComponent(encodedName); + agencies.push({ id: decodedName, name: decodedName }); } } return Response.json({ agencies }); @@ -132,14 +136,17 @@ const createAgency = async (req: IRequest, { env }: RequestContext) => { return new Response("Agency name is required", { status: 400 }); } - if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + // Allow alphanumeric, dashes, underscores, and slashes (for owner/repo pattern) + if (!/^[a-zA-Z0-9_\-\/]+$/.test(name)) { return new Response( - "Agency name must be alphanumeric with dashes/underscores only", + "Agency name must be alphanumeric with dashes, underscores, or slashes", { status: 400 } ); } - const existing = await env.FS.head(`${name}/.agency.json`); + // Encode the name for R2 path to handle slashes + const encodedName = encodeURIComponent(name); + const existing = await env.FS.head(`${encodedName}/.agency.json`); if (existing) { return new Response(`Agency '${name}' already exists`, { status: 409 }); } @@ -149,7 +156,7 @@ const createAgency = async (req: IRequest, { env }: RequestContext) => { name: name, createdAt: new Date().toISOString(), }; - await env.FS.put(`${name}/.agency.json`, JSON.stringify(meta)); + await env.FS.put(`${encodedName}/.agency.json`, JSON.stringify(meta)); return Response.json(meta, { status: 201 }); }; From 70b7dd5eae3cf0ffa33408f781178812d52dc0e2 Mon Sep 17 00:00:00 2001 From: Steve James Date: Tue, 13 Jan 2026 13:28:11 +0100 Subject: [PATCH 3/5] Revert "fix agency registration and url-encode agency ids in R2 paths" This reverts commit 7a9cc25ed29950459ce6e5b80f0e417ecb114633. --- lib/runtime/agency.ts | 49 +++++-------------------------------------- lib/runtime/worker.ts | 25 ++++++++-------------- 2 files changed, 14 insertions(+), 60 deletions(-) diff --git a/lib/runtime/agency.ts b/lib/runtime/agency.ts index 09bd0d5..0faf905 100644 --- a/lib/runtime/agency.ts +++ b/lib/runtime/agency.ts @@ -155,39 +155,6 @@ export class Agency extends Agent { this._cachedAgencyName = stored; } else { this.persistName(this.name); - // Register this agency in R2 so it appears in listAgencies - this.ensureRegisteredInR2(this.name); - } - } - - /** - * Ensure this agency is registered in R2 with a .agency.json file. - * This allows the agency to appear in listAgencies even if it was - * created implicitly (via direct DO access rather than POST /agencies). - */ - private async ensureRegisteredInR2(agencyId: string): Promise { - try { - const bucket = this.env.FS; - if (!bucket) return; - - // Encode the agency ID for R2 path (handles slashes in owner/repo) - const encodedId = encodeURIComponent(agencyId); - const metaPath = `${encodedId}/.agency.json`; - - // Check if already registered - const existing = await bucket.head(metaPath); - if (existing) return; - - // Create the agency metadata file - const meta = { - id: agencyId, - name: agencyId, - createdAt: new Date().toISOString(), - }; - await bucket.put(metaPath, JSON.stringify(meta)); - } catch (e) { - // Log but don't fail - registration is best-effort - console.error("[Agency] Failed to register in R2:", e); } } @@ -275,11 +242,6 @@ export class Agency extends Agent { ); } - /** Get the URL-encoded agency name for use in R2 paths */ - private get encodedAgencyName(): string { - return encodeURIComponent(this.agencyName); - } - private persistName(name: string): void { if (this._cachedAgencyName === name) return; this._cachedAgencyName = name; @@ -1320,7 +1282,7 @@ export class Agency extends Agent { const bucket = this.env.FS; if (bucket) { - await this.deletePrefix(bucket, `${this.encodedAgencyName}/`); + await this.deletePrefix(bucket, `${this.agencyName}/`); } await this.destroy(); @@ -1357,8 +1319,8 @@ export class Agency extends Agent { const bucket = this.env.FS; if (bucket) { - await this.deletePrefix(bucket, `${this.encodedAgencyName}/agents/${agentId}/`); - await bucket.delete(`${this.encodedAgencyName}/agents/${agentId}`).catch(() => {}); + await this.deletePrefix(bucket, `${this.agencyName}/agents/${agentId}/`); + await bucket.delete(`${this.agencyName}/agents/${agentId}`).catch(() => {}); } this.sql` @@ -1444,9 +1406,8 @@ export class Agency extends Agent { }); } - // Build R2 key: /{encodedAgencyId}/{fsPath} - // Use encoded agency name to handle slashes in agency IDs (e.g., owner/repo) - const r2Prefix = this.encodedAgencyName + "/"; + // Build R2 key: /{agencyId}/{fsPath} + const r2Prefix = this.agencyName + "/"; const r2Key = r2Prefix + fsPath; switch (req.method) { diff --git a/lib/runtime/worker.ts b/lib/runtime/worker.ts index 7753781..631ce67 100644 --- a/lib/runtime/worker.ts +++ b/lib/runtime/worker.ts @@ -105,24 +105,20 @@ const getPlugins = (req: IRequest, { opts }: RequestContext) => { const listAgencies = async (req: IRequest, { env }: RequestContext) => { const agencies = []; - // List all .agency.json files - they're stored with encoded agency IDs as prefixes const list = await env.FS.list({ delimiter: "/" }); for (const prefix of list.delimitedPrefixes) { - const encodedName = prefix.replace(/\/$/, ""); - const metaObj = await env.FS.get(`${encodedName}/.agency.json`); + const agencyName = prefix.replace(/\/$/, ""); + const metaObj = await env.FS.get(`${agencyName}/.agency.json`); if (metaObj) { try { const meta = await metaObj.json(); agencies.push(meta); } catch { - // Corrupted or empty .agency.json - decode the name for display - const decodedName = decodeURIComponent(encodedName); - agencies.push({ id: decodedName, name: decodedName }); + // Corrupted or empty .agency.json - use defaults + agencies.push({ id: agencyName, name: agencyName }); } } else { - // No .agency.json but prefix exists - decode the name for display - const decodedName = decodeURIComponent(encodedName); - agencies.push({ id: decodedName, name: decodedName }); + agencies.push({ id: agencyName, name: agencyName }); } } return Response.json({ agencies }); @@ -136,17 +132,14 @@ const createAgency = async (req: IRequest, { env }: RequestContext) => { return new Response("Agency name is required", { status: 400 }); } - // Allow alphanumeric, dashes, underscores, and slashes (for owner/repo pattern) - if (!/^[a-zA-Z0-9_\-\/]+$/.test(name)) { + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { return new Response( - "Agency name must be alphanumeric with dashes, underscores, or slashes", + "Agency name must be alphanumeric with dashes/underscores only", { status: 400 } ); } - // Encode the name for R2 path to handle slashes - const encodedName = encodeURIComponent(name); - const existing = await env.FS.head(`${encodedName}/.agency.json`); + const existing = await env.FS.head(`${name}/.agency.json`); if (existing) { return new Response(`Agency '${name}' already exists`, { status: 409 }); } @@ -156,7 +149,7 @@ const createAgency = async (req: IRequest, { env }: RequestContext) => { name: name, createdAt: new Date().toISOString(), }; - await env.FS.put(`${encodedName}/.agency.json`, JSON.stringify(meta)); + await env.FS.put(`${name}/.agency.json`, JSON.stringify(meta)); return Response.json(meta, { status: 201 }); }; From 098897b4b23c721376d9bb77788386ac82dbd62f Mon Sep 17 00:00:00 2001 From: Steve James Date: Tue, 13 Jan 2026 13:30:00 +0100 Subject: [PATCH 4/5] require explicit agency creation before operations - Added requireAgency() helper that returns 404 if agency doesn't exist - Applied to listAgents, createAgent, and vars endpoints - Prevents implicit agency creation via DO access --- lib/runtime/worker.ts | 57 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/lib/runtime/worker.ts b/lib/runtime/worker.ts index 631ce67..87e1d45 100644 --- a/lib/runtime/worker.ts +++ b/lib/runtime/worker.ts @@ -161,6 +161,33 @@ async function getAgencyStub(agencyId: string, ctx: CfCtx): Promise { + if (!env.FS) return true; // No R2 bucket = skip check + const metaObj = await env.FS.head(`${agencyId}/.agency.json`); + return metaObj !== null; +} + +/** + * Require agency to exist before proceeding. Returns 404 Response if not found. + * Use in route handlers: const error = await requireAgency(...); if (error) return error; + */ +async function requireAgency(agencyId: string, env: HandlerEnv): Promise { + const decodedId = decodeURIComponent(agencyId); + const exists = await agencyExists(decodedId, env); + if (!exists) { + return new Response( + JSON.stringify({ + error: "Agency not found", + message: `Agency '${decodedId}' does not exist. Create it first with POST /agencies`, + agencyId: decodedId, + }), + { status: 404, headers: { "content-type": "application/json" } } + ); + } + return null; +} + const deleteAgency = async (req: IRequest, { ctx }: RequestContext) => { const agencyStub = await getAgencyStub(req.params.agencyId, ctx); return agencyStub.fetch(new Request("http://do/destroy", { method: "DELETE" })); @@ -192,12 +219,18 @@ const deleteBlueprint = async (req: IRequest, { ctx }: RequestContext) => { ); }; -const listAgents = async (req: IRequest, { ctx }: RequestContext) => { +const listAgents = async (req: IRequest, { ctx, env }: RequestContext) => { + const notFound = await requireAgency(req.params.agencyId, env); + if (notFound) return notFound; + const agencyStub = await getAgencyStub(req.params.agencyId, ctx); return agencyStub.fetch(new Request("http://do/agents")); }; -const createAgent = async (req: IRequest, { ctx }: RequestContext) => { +const createAgent = async (req: IRequest, { ctx, env }: RequestContext) => { + const notFound = await requireAgency(req.params.agencyId, env); + if (notFound) return notFound; + const agencyStub = await getAgencyStub(req.params.agencyId, ctx); const body = await req.json>(); body.requestContext = buildRequestContext(req); @@ -295,12 +328,18 @@ const getScheduleRuns = async (req: IRequest, { ctx }: RequestContext) => { // --- Vars --- -const getVars = async (req: IRequest, { ctx }: RequestContext) => { +const getVars = async (req: IRequest, { ctx, env }: RequestContext) => { + const notFound = await requireAgency(req.params.agencyId, env); + if (notFound) return notFound; + const agencyStub = await getAgencyStub(req.params.agencyId, ctx); return agencyStub.fetch(new Request("http://do/vars")); }; -const setVars = async (req: IRequest, { ctx }: RequestContext) => { +const setVars = async (req: IRequest, { ctx, env }: RequestContext) => { + const notFound = await requireAgency(req.params.agencyId, env); + if (notFound) return notFound; + const agencyStub = await getAgencyStub(req.params.agencyId, ctx); return agencyStub.fetch( new Request("http://do/vars", { @@ -311,12 +350,18 @@ const setVars = async (req: IRequest, { ctx }: RequestContext) => { ); }; -const getVar = async (req: IRequest, { ctx }: RequestContext) => { +const getVar = async (req: IRequest, { ctx, env }: RequestContext) => { + const notFound = await requireAgency(req.params.agencyId, env); + if (notFound) return notFound; + const agencyStub = await getAgencyStub(req.params.agencyId, ctx); return agencyStub.fetch(new Request(`http://do/vars/${req.params.varKey}`)); }; -const setVar = async (req: IRequest, { ctx }: RequestContext) => { +const setVar = async (req: IRequest, { ctx, env }: RequestContext) => { + const notFound = await requireAgency(req.params.agencyId, env); + if (notFound) return notFound; + const agencyStub = await getAgencyStub(req.params.agencyId, ctx); return agencyStub.fetch( new Request(`http://do/vars/${req.params.varKey}`, { From 3168fd3ce0739c87f50b58536b19dd4474f236ee Mon Sep 17 00:00:00 2001 From: Steve James Date: Tue, 13 Jan 2026 14:01:01 +0100 Subject: [PATCH 5/5] fix context plugin: use user role for summary messages Many LLMs (including Z.AI) don't allow conversations starting with assistant messages. Changed summary injection to use 'user' role. --- lib/runtime/plugins/context.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/runtime/plugins/context.ts b/lib/runtime/plugins/context.ts index 57c1cf8..f870e12 100644 --- a/lib/runtime/plugins/context.ts +++ b/lib/runtime/plugins/context.ts @@ -247,8 +247,8 @@ export const context: AgentPlugin = { const recentMessages = store.getMessagesAfter(checkpointEndSeq); plan.setMessages([ { - role: "assistant", - content: `[Conversation Summary]\n${checkpoint.summary}`, + role: "user", + content: `[Previous Conversation Summary]\n${checkpoint.summary}\n\n---\nContinue from where we left off.`, }, ...recentMessages.filter((m: ChatMessage) => m.role !== "system"), ]); @@ -349,10 +349,11 @@ export const context: AgentPlugin = { }); // Set messages for this request: summary + recent messages + // Use "user" role for summary since many LLMs don't allow starting with "assistant" plan.setMessages([ { - role: "assistant", - content: `[Conversation Summary]\n${summary}`, + role: "user", + content: `[Previous Conversation Summary]\n${summary}\n\n---\nContinue from where we left off.`, }, ...toKeep.filter((m: ChatMessage) => m.role !== "system"), ]);