feat(sessions): expose label in sessions.list and support label lookup in sessions_send

- Add `label` field to session entries and expose it in `sessions.list`
- Display label column in the web UI sessions table
- Support `label` parameter in `sessions_send` for lookup by label instead of sessionKey

- `sessions.patch`: Accept and store `label` field
- `sessions.list`: Return `label` in session entries
- `sessions_spawn`: Pass label through to registry and announce flow
- `sessions_send`: Accept optional `label` param, lookup session by label if sessionKey not provided
- `agent` method: Accept `label` and `spawnedBy` params (stored in session entry)

- Add `label` column to sessions table in web UI

- Changed session store writes to merge with existing entry (`{ ...existing, ...new }`)
  to preserve fields like `label` that might be set separately

We attempted to implement label persistence "properly" by passing the label
through the `agent` call and storing it during session initialization. However,
the auto-reply flow has multiple write points that overwrite the session entry,
and making all of them merge-aware proved unreliable.

The working solution patches the label in the `finally` block of
`runSubagentAnnounceFlow`, after all other session writes complete.
This is a workaround but robust - the patch happens at the very end,
just before potential cleanup.

A future refactor could make session writes consistently merge-based,
which would allow the cleaner approach of setting label at spawn time.

```typescript
// Spawn with label
sessions_spawn({ task: "...", label: "my-worker" })

// Later, find by label
sessions_send({ label: "my-worker", message: "continue..." })

// Or use sessions_list to see labels
sessions_list() // includes label field in response
```
This commit is contained in:
Azade
2026-01-08 23:17:08 +00:00
committed by Peter Steinberger
parent b85854d0fe
commit 3133c7c84e
20 changed files with 142 additions and 27 deletions

View File

@@ -25,6 +25,7 @@ type SessionListRow = {
key: string;
kind: SessionKind;
provider: string;
label?: string;
displayName?: string;
updatedAt?: number | null;
sessionId?: string;
@@ -205,6 +206,7 @@ export function createSessionsListTool(opts?: {
key: displayKey,
kind,
provider: derivedProvider,
label: typeof entry.label === "string" ? entry.label : undefined,
displayName:
typeof entry.displayName === "string"
? entry.displayName

View File

@@ -30,7 +30,8 @@ import {
} from "./sessions-send-helpers.js";
const SessionsSendToolSchema = Type.Object({
sessionKey: Type.String(),
sessionKey: Type.Optional(Type.String()),
label: Type.Optional(Type.String()),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
});
@@ -43,15 +44,41 @@ export function createSessionsSendTool(opts?: {
return {
label: "Session Send",
name: "sessions_send",
description: "Send a message into another session.",
description:
"Send a message into another session. Use sessionKey or label to identify the target.",
parameters: SessionsSendToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const sessionKey = readStringParam(params, "sessionKey", {
required: true,
});
let sessionKey = readStringParam(params, "sessionKey");
const labelParam = readStringParam(params, "label");
const message = readStringParam(params, "message", { required: true });
const cfg = loadConfig();
// Lookup by label if sessionKey not provided
if (!sessionKey && labelParam) {
const listResult = (await callGateway({
method: "sessions.list",
params: { activeMinutes: 1440 }, // Last 24h
timeoutMs: 10_000,
})) as { sessions?: Array<{ key: string; label?: string }> };
const match = listResult.sessions?.find(
(s) => s.label === labelParam,
);
if (!match) {
return jsonResult({
status: "error",
error: `No session found with label: ${labelParam}`,
});
}
sessionKey = match.key;
}
if (!sessionKey) {
return jsonResult({
status: "error",
error: "Either sessionKey or label is required",
});
}
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const visibility =
cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";

View File

@@ -126,17 +126,7 @@ export function createSessionsSpawnTool(opts?: {
}
}
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
if (opts?.sandboxed === true) {
try {
await callGateway({
method: "sessions.patch",
params: { key: childSessionKey, spawnedBy: requesterInternalKey },
timeoutMs: 10_000,
});
} catch {
// best-effort; scoping relies on this metadata but spawning still works without it
}
}
const shouldPatchSpawnedBy = opts?.sandboxed === true;
if (model) {
try {
await callGateway({
@@ -185,6 +175,8 @@ export function createSessionsSpawnTool(opts?: {
lane: "subagent",
extraSystemPrompt: childSystemPrompt,
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
label: label || undefined,
spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined,
},
timeoutMs: 10_000,
})) as { runId?: string };
@@ -214,6 +206,7 @@ export function createSessionsSpawnTool(opts?: {
requesterDisplayKey,
task,
cleanup,
label: label || undefined,
});
return jsonResult({