Skip to content

Commit 191fdb6

Browse files
rjcloudsigmacloudsigma
andauthored
feat: index autorouter captures by agentId for multi-agent gateway (#8)
Adds a second in-memory index keyed by a derived agent identifier (env OPENCLAW_AGENT_ID/RUN_ID first, then trailing path segment of agentDir/workspaceDir — "workspace" -> "main", "workspace-new-agent-3" -> "new-agent-3"). Extends the taas.autorouter.lastRoute RPC to accept { agentId } and prefer the agent-keyed lookup. Studio passes its known agentId so each agent gets ITS routing metadata in a multi-agent gateway process. Smoke test covers the new path. Co-authored-by: cloudsigma <cloudsigma@snowcrash.tail77dc93.ts.net>
1 parent 520efe7 commit 191fdb6

2 files changed

Lines changed: 126 additions & 5 deletions

File tree

index.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,7 @@ type AutorouterCapture = {
638638

639639
const LAST_ROUTE_LIMIT = 256
640640
const lastRouteBySessionId = new Map<string, AutorouterCapture>()
641+
const lastRouteByAgentId = new Map<string, AutorouterCapture>()
641642

642643
function pruneLastRouteMap(): void {
643644
if (lastRouteBySessionId.size <= LAST_ROUTE_LIMIT) return
@@ -649,11 +650,41 @@ function pruneLastRouteMap(): void {
649650
for (let i = 0; i < toDrop; i++) {
650651
lastRouteBySessionId.delete(entries[i][0])
651652
}
653+
if (lastRouteByAgentId.size <= LAST_ROUTE_LIMIT) return
654+
const aEntries = [...lastRouteByAgentId.entries()].sort(
655+
(a, b) => a[1].capturedAt - b[1].capturedAt
656+
)
657+
const aDrop = aEntries.length - LAST_ROUTE_LIMIT
658+
for (let i = 0; i < aDrop; i++) {
659+
lastRouteByAgentId.delete(aEntries[i][0])
660+
}
661+
}
662+
663+
/**
664+
* Derive a stable agent identifier from the OpenClaw runtime context. Prefers
665+
* explicit env vars set by the gateway for sub-agents (OPENCLAW_AGENT_ID /
666+
* OPENCLAW_RUN_ID), then falls back to the trailing path segment of agentDir
667+
* or workspaceDir (e.g. /home/u/.openclaw/workspace-new-agent-3 -> "new-agent-3",
668+
* /home/u/.openclaw/workspace -> "main").
669+
*/
670+
function deriveAgentIdForCapture(
671+
ctx: { agentDir?: string; workspaceDir?: string }
672+
): string | null {
673+
const envAgent = process.env.OPENCLAW_AGENT_ID ?? process.env.OPENCLAW_RUN_ID
674+
if (envAgent && envAgent.trim()) return envAgent.trim()
675+
const base = ctx.agentDir ?? ctx.workspaceDir
676+
if (!base) return null
677+
const seg = path.basename(path.resolve(base))
678+
if (!seg) return null
679+
if (seg === "workspace") return "main"
680+
if (seg.startsWith("workspace-")) return seg.slice("workspace-".length)
681+
return seg
652682
}
653683

654684
function captureAutorouterFromHeaders(
655685
sessionId: string,
656-
headers: Record<string, string>
686+
headers: Record<string, string>,
687+
agentId: string | null
657688
): void {
658689
// Header names from TaaS proxy are emitted in canonical "X-TaaS-*" form
659690
// but Node/undici lowercases incoming response headers. Read case-insensitively.
@@ -678,6 +709,7 @@ function captureAutorouterFromHeaders(
678709
})(),
679710
}
680711
lastRouteBySessionId.set(sessionId, capture)
712+
if (agentId) lastRouteByAgentId.set(agentId, capture)
681713
pruneLastRouteMap()
682714
if (isDev) {
683715
console.debug(
@@ -693,11 +725,16 @@ function getLastRouteForSession(sessionId: string): AutorouterCapture | null {
693725
return lastRouteBySessionId.get(sessionId) ?? null
694726
}
695727

728+
function getLastRouteForAgent(agentId: string): AutorouterCapture | null {
729+
return lastRouteByAgentId.get(agentId) ?? null
730+
}
731+
696732
function buildWrapper(ctx: ProviderWrapStreamFnContext) {
697733
const { streamFn } = ctx
698734
if (!streamFn) return undefined
699735

700736
const { sessionId, source } = resolveSessionId(ctx.workspaceDir)
737+
const agentIdForCapture = deriveAgentIdForCapture(ctx)
701738
const requesterRuntime = buildRequesterRuntime(ctx, sessionId, source)
702739

703740
if (isDev) {
@@ -727,7 +764,11 @@ function buildWrapper(ctx: ProviderWrapStreamFnContext) {
727764
responseModel
728765
) => {
729766
try {
730-
captureAutorouterFromHeaders(sessionId, response?.headers ?? {})
767+
captureAutorouterFromHeaders(
768+
sessionId,
769+
response?.headers ?? {},
770+
agentIdForCapture
771+
)
731772
} catch (err) {
732773
if (isDev) {
733774
console.debug(
@@ -806,11 +847,27 @@ export default {
806847
async ({ params, respond }) => {
807848
// Accept either { workspaceDir } (preferred — derives sessionId the
808849
// same way the wrapper does) or { sessionId } (direct lookup).
809-
const p = (params ?? {}) as Record<string, unknown>
850+
const pp = (params ?? {}) as Record<string, unknown>
851+
const directAgentId =
852+
typeof pp.agentId === "string" && pp.agentId.trim()
853+
? pp.agentId.trim()
854+
: null
810855
const directSessionId =
811-
typeof p.sessionId === "string" ? p.sessionId : null
856+
typeof pp.sessionId === "string" ? pp.sessionId : null
812857
const workspaceDir =
813-
typeof p.workspaceDir === "string" ? p.workspaceDir : undefined
858+
typeof pp.workspaceDir === "string" ? pp.workspaceDir : undefined
859+
860+
// Prefer agent-keyed lookup when the caller supplied an agentId.
861+
if (directAgentId) {
862+
const captured = getLastRouteForAgent(directAgentId)
863+
respond(true, {
864+
agentId: directAgentId,
865+
sessionId: captured?.sessionId ?? null,
866+
capture: captured,
867+
})
868+
return
869+
}
870+
814871
const resolvedSessionId =
815872
directSessionId ?? resolveSessionId(workspaceDir).sessionId
816873
const captured = getLastRouteForSession(resolvedSessionId)

test/smoke.mjs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,67 @@ await registeredHandler({
160160
})
161161

162162
console.log("autorouter capture smoke ok")
163+
164+
// === per-agent keying ===
165+
// When the Studio passes { agentId }, the plugin should return the capture
166+
// stored under that agent's key (derived from agentDir/workspaceDir or env).
167+
{
168+
// First simulate a capture happening for an agent named "new-agent-3"
169+
const agentWrapped = provider.wrapStreamFn({
170+
streamFn: async (_m, _c, options = {}) => {
171+
if (options.onResponse) {
172+
await options.onResponse(
173+
{
174+
status: 200,
175+
headers: {
176+
"x-taas-autorouted": "true",
177+
"x-taas-autorouter-model": "cloudsigma/gpt-5-mini",
178+
"x-taas-autorouter-mode": "price_performance",
179+
"x-taas-autorouter-algorithm-source": "user_default",
180+
"x-taas-thinking-applied": "low",
181+
"x-taas-routed-context-window": "200000",
182+
},
183+
},
184+
_m
185+
)
186+
}
187+
},
188+
workspaceDir: "/home/cloudsigma/.openclaw/workspace-new-agent-3",
189+
agentDir: "/home/cloudsigma/.openclaw/workspace-new-agent-3",
190+
provider: "cloudsigma",
191+
modelId: "cloudsigma/auto",
192+
model: { id: "cloudsigma/auto" },
193+
})
194+
await agentWrapped("model", { messages: [] }, {})
195+
196+
// Now ask via { agentId: "new-agent-3" }
197+
let agentPayload
198+
await registeredHandler({
199+
req: { id: "t-agent" },
200+
params: { agentId: "new-agent-3" },
201+
client: null,
202+
isWebchatConnect: () => false,
203+
respond: (_ok, payload) => { agentPayload = payload },
204+
context: {},
205+
})
206+
assert.ok(agentPayload?.capture, "agentId lookup returned a capture")
207+
assert.equal(agentPayload.agentId, "new-agent-3")
208+
assert.equal(agentPayload.capture.autorouterModel, "cloudsigma/gpt-5-mini")
209+
assert.equal(agentPayload.capture.autorouterAlgo, "price_performance")
210+
assert.equal(agentPayload.capture.routedContextWindow, 200000)
211+
212+
// And a non-matching agentId returns null capture
213+
let missPayload
214+
await registeredHandler({
215+
req: { id: "t-miss" },
216+
params: { agentId: "no-such-agent" },
217+
client: null,
218+
isWebchatConnect: () => false,
219+
respond: (_ok, payload) => { missPayload = payload },
220+
context: {},
221+
})
222+
assert.equal(missPayload.agentId, "no-such-agent")
223+
assert.equal(missPayload.capture, null, "miss returns null capture")
224+
}
225+
226+
console.log("per-agent keying smoke ok")

0 commit comments

Comments
 (0)