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:
@@ -196,6 +196,7 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
waitForCompletion?: boolean;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
label?: string;
|
||||
}) {
|
||||
try {
|
||||
let reply = params.roundOneReply;
|
||||
@@ -273,6 +274,18 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
} catch {
|
||||
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
|
||||
} finally {
|
||||
// Patch label after all writes complete
|
||||
if (params.label) {
|
||||
try {
|
||||
await callGateway({
|
||||
method: "sessions.patch",
|
||||
params: { key: params.childSessionKey, label: params.label },
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
if (params.cleanup === "delete") {
|
||||
try {
|
||||
await callGateway({
|
||||
|
||||
@@ -11,6 +11,7 @@ export type SubagentRunRecord = {
|
||||
requesterDisplayKey: string;
|
||||
task: string;
|
||||
cleanup: "delete" | "keep";
|
||||
label?: string;
|
||||
createdAt: number;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
@@ -83,6 +84,7 @@ function ensureListener() {
|
||||
? (evt.data.endedAt as number)
|
||||
: Date.now();
|
||||
entry.endedAt = endedAt;
|
||||
|
||||
if (!beginSubagentAnnounce(evt.runId)) {
|
||||
if (entry.cleanup === "delete") {
|
||||
subagentRuns.delete(evt.runId);
|
||||
@@ -101,6 +103,7 @@ function ensureListener() {
|
||||
waitForCompletion: false,
|
||||
startedAt: entry.startedAt,
|
||||
endedAt: entry.endedAt,
|
||||
label: entry.label,
|
||||
});
|
||||
if (entry.cleanup === "delete") {
|
||||
subagentRuns.delete(evt.runId);
|
||||
@@ -124,6 +127,7 @@ export function registerSubagentRun(params: {
|
||||
requesterDisplayKey: string;
|
||||
task: string;
|
||||
cleanup: "delete" | "keep";
|
||||
label?: string;
|
||||
}) {
|
||||
const now = Date.now();
|
||||
const archiveAfterMs = resolveArchiveAfterMs();
|
||||
@@ -136,6 +140,7 @@ export function registerSubagentRun(params: {
|
||||
requesterDisplayKey: params.requesterDisplayKey,
|
||||
task: params.task,
|
||||
cleanup: params.cleanup,
|
||||
label: params.label,
|
||||
createdAt: now,
|
||||
startedAt: now,
|
||||
archiveAtMs,
|
||||
@@ -175,6 +180,7 @@ async function probeImmediateCompletion(runId: string) {
|
||||
waitForCompletion: false,
|
||||
startedAt: entry.startedAt,
|
||||
endedAt: entry.endedAt,
|
||||
label: entry.label,
|
||||
});
|
||||
if (entry.cleanup === "delete") {
|
||||
subagentRuns.delete(runId);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user