feat: refine subagents + add chat.inject

Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-15 23:06:58 +00:00
parent 688a0ce439
commit a4b347b454
22 changed files with 632 additions and 533 deletions

View File

@@ -22,6 +22,8 @@ import {
type ChatEvent,
ChatEventSchema,
ChatHistoryParamsSchema,
type ChatInjectParams,
ChatInjectParamsSchema,
ChatSendParamsSchema,
type ConfigApplyParams,
ConfigApplyParamsSchema,
@@ -232,6 +234,7 @@ export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParams
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
export const validateChatAbortParams = ajv.compile<ChatAbortParams>(ChatAbortParamsSchema);
export const validateChatInjectParams = ajv.compile<ChatInjectParams>(ChatInjectParamsSchema);
export const validateChatEvent = ajv.compile(ChatEventSchema);
export const validateUpdateRunParams = ajv.compile<UpdateRunParams>(UpdateRunParamsSchema);
export const validateWebLoginStartParams =
@@ -310,6 +313,7 @@ export {
LogsTailResultSchema,
ChatHistoryParamsSchema,
ChatSendParamsSchema,
ChatInjectParamsSchema,
UpdateRunParamsSchema,
TickEventSchema,
ShutdownEventSchema,
@@ -388,4 +392,5 @@ export type {
LogsTailResult,
PollParams,
UpdateRunParams,
ChatInjectParams,
};

View File

@@ -53,6 +53,15 @@ export const ChatAbortParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ChatInjectParamsSchema = Type.Object(
{
sessionKey: NonEmptyString,
message: NonEmptyString,
label: Type.Optional(Type.String({ maxLength: 100 })),
},
{ additionalProperties: false },
);
export const ChatEventSchema = Type.Object(
{
runId: NonEmptyString,

View File

@@ -62,6 +62,7 @@ import {
ChatAbortParamsSchema,
ChatEventSchema,
ChatHistoryParamsSchema,
ChatInjectParamsSchema,
ChatSendParamsSchema,
LogsTailParamsSchema,
LogsTailResultSchema,
@@ -172,6 +173,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
ChatHistoryParams: ChatHistoryParamsSchema,
ChatSendParams: ChatSendParamsSchema,
ChatAbortParams: ChatAbortParamsSchema,
ChatInjectParams: ChatInjectParamsSchema,
ChatEvent: ChatEventSchema,
UpdateRunParams: UpdateRunParamsSchema,
TickEvent: TickEventSchema,

View File

@@ -59,6 +59,7 @@ import type {
import type {
ChatAbortParamsSchema,
ChatEventSchema,
ChatInjectParamsSchema,
LogsTailParamsSchema,
LogsTailResultSchema,
} from "./logs-chat.js";
@@ -163,6 +164,7 @@ export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
export type ChatEvent = Static<typeof ChatEventSchema>;
export type UpdateRunParams = Static<typeof UpdateRunParamsSchema>;
export type TickEvent = Static<typeof TickEventSchema>;

View File

@@ -1,8 +1,10 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveThinkingDefault } from "../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
import { agentCommand } from "../commands/agent.js";
import { mergeSessionEntry, saveSessionStore } from "../config/sessions.js";
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
import { registerAgentRunContext } from "../infra/agent-events.js";
import { defaultRuntime } from "../runtime.js";
import {
@@ -17,6 +19,7 @@ import {
errorShape,
formatValidationErrors,
validateChatAbortParams,
validateChatInjectParams,
validateChatHistoryParams,
validateChatSendParams,
} from "./protocol/index.js";
@@ -31,6 +34,84 @@ import {
export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId, method, params) => {
switch (method) {
case "chat.inject": {
if (!validateChatInjectParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid chat.inject params: ${formatValidationErrors(validateChatInjectParams.errors)}`,
},
};
}
const p = params as {
sessionKey: string;
message: string;
label?: string;
};
const { storePath, entry } = loadSessionEntry(p.sessionKey);
const sessionId = entry?.sessionId;
if (!sessionId || !storePath) {
return {
ok: false,
error: { code: ErrorCodes.INVALID_REQUEST, message: "session not found" },
};
}
const transcriptPath = entry?.sessionFile
? entry.sessionFile
: path.join(path.dirname(storePath), `${sessionId}.jsonl`);
if (!fs.existsSync(transcriptPath)) {
return {
ok: false,
error: { code: ErrorCodes.INVALID_REQUEST, message: "transcript file not found" },
};
}
const now = Date.now();
const messageId = randomUUID().slice(0, 8);
const labelPrefix = p.label ? `[${p.label}]\n\n` : "";
const messageBody: Record<string, unknown> = {
role: "assistant",
content: [{ type: "text", text: `${labelPrefix}${p.message}` }],
timestamp: now,
stopReason: "injected",
usage: { input: 0, output: 0, totalTokens: 0 },
};
const transcriptEntry = {
type: "message",
id: messageId,
timestamp: new Date(now).toISOString(),
message: messageBody,
};
try {
fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
} catch (err) {
const errMessage = err instanceof Error ? err.message : String(err);
return {
ok: false,
error: {
code: ErrorCodes.UNAVAILABLE,
message: `failed to write transcript: ${errMessage}`,
},
};
}
const chatPayload = {
runId: `inject-${messageId}`,
sessionKey: p.sessionKey,
seq: 0,
state: "final" as const,
message: transcriptEntry.message,
};
ctx.broadcast("chat", chatPayload);
ctx.bridgeSendToSession(p.sessionKey, "chat", chatPayload);
return { ok: true, payloadJSON: JSON.stringify({ ok: true, messageId }) };
}
case "chat.history": {
if (!validateChatHistoryParams(params)) {
return {
@@ -217,7 +298,7 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
}
}
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
const timeoutMs = resolveAgentTimeoutMs({
cfg,
overrideMs: p.timeoutMs,
@@ -294,11 +375,10 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
clientRunId,
});
if (store) {
store[canonicalKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, store);
}
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[canonicalKey] = sessionEntry;
});
}
const ackPayload = {

View File

@@ -1,9 +1,11 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveThinkingDefault } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { agentCommand } from "../../commands/agent.js";
import { mergeSessionEntry, saveSessionStore } from "../../config/sessions.js";
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
@@ -21,6 +23,7 @@ import {
formatValidationErrors,
validateChatAbortParams,
validateChatHistoryParams,
validateChatInjectParams,
validateChatSendParams,
} from "../protocol/index.js";
import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "../server-constants.js";
@@ -205,7 +208,7 @@ export const chatHandlers: GatewayRequestHandlers = {
return;
}
}
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
const timeoutMs = resolveAgentTimeoutMs({
cfg,
overrideMs: p.timeoutMs,
@@ -284,11 +287,10 @@ export const chatHandlers: GatewayRequestHandlers = {
clientRunId,
});
if (store) {
store[canonicalKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, store);
}
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[canonicalKey] = sessionEntry;
});
}
const ackPayload = {
@@ -355,4 +357,80 @@ export const chatHandlers: GatewayRequestHandlers = {
});
}
},
"chat.inject": async ({ params, respond, context }) => {
if (!validateChatInjectParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid chat.inject params: ${formatValidationErrors(validateChatInjectParams.errors)}`,
),
);
return;
}
const p = params as {
sessionKey: string;
message: string;
label?: string;
};
// Load session to find transcript file
const { storePath, entry } = loadSessionEntry(p.sessionKey);
const sessionId = entry?.sessionId;
if (!sessionId || !storePath) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "session not found"));
return;
}
// Resolve transcript path
const transcriptPath = entry?.sessionFile
? entry.sessionFile
: path.join(path.dirname(storePath), `${sessionId}.jsonl`);
if (!fs.existsSync(transcriptPath)) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "transcript file not found"));
return;
}
// Build transcript entry
const now = Date.now();
const messageId = randomUUID().slice(0, 8);
const labelPrefix = p.label ? `[${p.label}]\n\n` : "";
const messageBody: Record<string, unknown> = {
role: "assistant",
content: [{ type: "text", text: `${labelPrefix}${p.message}` }],
timestamp: now,
stopReason: "injected",
usage: { input: 0, output: 0, totalTokens: 0 },
};
const transcriptEntry = {
type: "message",
id: messageId,
timestamp: new Date(now).toISOString(),
message: messageBody,
};
// Append to transcript file
try {
fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
} catch (err) {
const errMessage = err instanceof Error ? err.message : String(err);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, `failed to write transcript: ${errMessage}`));
return;
}
// Broadcast to webchat for immediate UI update
const chatPayload = {
runId: `inject-${messageId}`,
sessionKey: p.sessionKey,
seq: 0,
state: "final" as const,
message: transcriptEntry.message,
};
context.broadcast("chat", chatPayload);
context.bridgeSendToSession(p.sessionKey, "chat", chatPayload);
respond(true, { ok: true, messageId });
},
};

View File

@@ -381,6 +381,60 @@ describe("gateway server chat", () => {
await server.close();
});
test("chat.inject appends to the session transcript", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
const transcriptPath = path.join(dir, "sess-main.jsonl");
await fs.writeFile(
transcriptPath,
`${JSON.stringify({
type: "message",
id: "m1",
timestamp: new Date().toISOString(),
message: { role: "user", content: [{ type: "text", text: "seed" }], timestamp: Date.now() },
})}\n`,
"utf-8",
);
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{ messageId?: string }>(ws, "chat.inject", {
sessionKey: "main",
message: "injected text",
label: "note",
});
expect(res.ok).toBe(true);
const raw = await fs.readFile(transcriptPath, "utf-8");
const lines = raw.split(/\r?\n/).filter(Boolean);
expect(lines.length).toBe(2);
const last = JSON.parse(lines[1]) as {
message?: { role?: string; content?: Array<{ text?: string }> };
};
expect(last.message?.role).toBe("assistant");
expect(last.message?.content?.[0]?.text).toContain("injected text");
ws.close();
await server.close();
});
test("chat.history defaults thinking to low for reasoning-capable models", async () => {
piSdkMock.enabled = true;
piSdkMock.models = [