Skip to content

Commit 7cbec09

Browse files
authored
Merge pull request #76 from shetty4l/fix/output-routing-and-cleanups
feat: add built-in tools, output routing, and idleTimeout
2 parents d332d83 + 2650f18 commit 7cbec09

10 files changed

Lines changed: 487 additions & 7 deletions

File tree

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"prepare": "husky"
2020
},
2121
"dependencies": {
22-
"@shetty4l/core": "^0.1.30"
22+
"@shetty4l/core": "^0.1.33"
2323
},
2424
"devDependencies": {
2525
"@biomejs/biome": "^2.3.13",

src/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ export interface CortexConfig {
6767
// Thalamus
6868
thalamusModel: string;
6969
thalamusSyncIntervalMs: number;
70+
71+
// Output routing
72+
silentChannelAlias?: string;
7073
}
7174

7275
// --- Defaults ---
@@ -150,6 +153,7 @@ function validateConfig(raw: unknown): Result<Partial<CortexConfig>> {
150153
{ key: "telegramBotToken", label: "telegramBotToken" },
151154
{ key: "systemPromptFile", label: "systemPromptFile" },
152155
{ key: "thalamusModel", label: "thalamusModel" },
156+
{ key: "silentChannelAlias", label: "silentChannelAlias" },
153157
];
154158

155159
for (const field of stringFields) {

src/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { startServer } from "./server";
2121
import { createEmptyRegistry, loadSkills, type SkillRegistry } from "./skills";
2222
import { Thalamus } from "./thalamus";
2323
import { Tick } from "./tick";
24+
import { type BuiltinToolContext, createCombinedRegistry } from "./tools";
25+
import { createSendMessageTool } from "./tools/send-message";
2426
import { VERSION } from "./version";
2527

2628
const log = createLogger("cortex");
@@ -81,10 +83,23 @@ export async function startCortexRuntime(
8183
const server = deps.startServer(config, thalamus);
8284
deps.log(`listening on http://${config.host}:${config.port}`);
8385

84-
const loop = deps.startProcessingLoop(config, registry);
86+
// Create channel registry (needed by built-in tools)
87+
const channels = deps.createChannelRegistry(config, thalamus);
88+
89+
// Create built-in tools with mutable per-message context
90+
const builtinCtx: BuiltinToolContext = { topicKey: "" };
91+
const builtinTools = [createSendMessageTool(channels)];
92+
const combinedRegistry = createCombinedRegistry(
93+
builtinTools,
94+
registry,
95+
() => builtinCtx,
96+
);
97+
98+
const loop = deps.startProcessingLoop(config, combinedRegistry, {
99+
builtinContext: builtinCtx,
100+
});
85101
deps.log("processing loop started");
86102

87-
const channels = deps.createChannelRegistry(config, thalamus);
88103
await channels.startAll();
89104

90105
await thalamus.start();

src/loop.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { buildPrompt, loadAndRenderSystemPrompt } from "./prompt";
3131
import type { SkillRegistry } from "./skills";
3232
import type { OpenAITool } from "./synapse";
3333
import { chat } from "./synapse";
34+
import type { BuiltinToolContext } from "./tools";
35+
import { resolveOutputChannel } from "./tools";
3436

3537
// --- Constants ---
3638

@@ -67,6 +69,8 @@ export interface ProcessingLoopOptions {
6769
pollBusyMs?: number;
6870
/** Override idle poll interval (ms). Default: 2000. */
6971
pollIdleMs?: number;
72+
/** Mutable context shared with built-in tools (topicKey updated per message). */
73+
builtinContext?: BuiltinToolContext;
7074
}
7175

7276
/**
@@ -85,6 +89,7 @@ export function startProcessingLoop(
8589
let running = true;
8690
const pollBusyMs = options?.pollBusyMs ?? DEFAULT_POLL_BUSY_MS;
8791
const pollIdleMs = options?.pollIdleMs ?? DEFAULT_POLL_IDLE_MS;
92+
const builtinCtx = options?.builtinContext;
8893

8994
if (!config.extractionModel) {
9095
log("extraction disabled — no extractionModel configured");
@@ -119,6 +124,11 @@ export function startProcessingLoop(
119124
delay = pollBusyMs;
120125
const startMs = performance.now();
121126

127+
// Update built-in tool context for this message
128+
if (builtinCtx) {
129+
builtinCtx.topicKey = message.topic_key;
130+
}
131+
122132
const preview =
123133
message.text.length > 60
124134
? `${message.text.slice(0, 57)}...`
@@ -232,7 +242,7 @@ export function startProcessingLoop(
232242

233243
// 8. Write to outbox
234244
enqueueOutboxMessage({
235-
channel: message.channel,
245+
channel: resolveOutputChannel(message.channel, config),
236246
topicKey: message.topic_key,
237247
text: responseText,
238248
});

src/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ export function startServer(
351351
port: config.port,
352352
host: config.host,
353353
version: VERSION,
354+
idleTimeout: 120,
354355
onRequest: async (req: Request, url: URL) => {
355356
const start = performance.now();
356357
let response: Response | null = null;

src/thalamus/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ export class Thalamus {
203203
// Create inbox messages for each triaged group
204204
for (const item of items) {
205205
const idempotencyHash = [...item.rawBufferIds].sort().join(",");
206-
const idempotencyKey = `thalamus-sync:${idempotencyHash}`;
206+
const idempotencyKey = `thalamus-sync:${item.topicKey}:${idempotencyHash}`;
207207

208208
enqueueInboxMessage({
209209
channel: "thalamus",

src/tools/index.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Built-in tools for Cortex.
3+
*
4+
* Built-in tools are first-party primitives that run inside the agent loop
5+
* with direct access to cortex internals (outbox, channels, config).
6+
* They are NOT namespaced — tool names are bare (e.g. "send_message").
7+
*
8+
* External skills loaded from skillDirs are namespaced as "skillId.toolName".
9+
*/
10+
11+
import type { Result } from "@shetty4l/core/result";
12+
import type { CortexConfig } from "../config";
13+
import type { SkillRegistry, SkillToolResult, ToolDefinition } from "../skills";
14+
15+
// --- Types ---
16+
17+
/** Mutable context updated per-message in the processing loop. */
18+
export interface BuiltinToolContext {
19+
/** Topic key of the message currently being processed. */
20+
topicKey: string;
21+
}
22+
23+
/** A built-in tool with direct access to cortex internals. */
24+
export interface BuiltinTool {
25+
readonly definition: ToolDefinition;
26+
execute(
27+
argsJson: string,
28+
ctx: BuiltinToolContext,
29+
): Promise<Result<SkillToolResult>>;
30+
}
31+
32+
// --- Output channel resolution ---
33+
34+
/** Channels that are internal (system) and should not receive user-facing responses. */
35+
const SYSTEM_CHANNELS = new Set(["thalamus", "calendar"]);
36+
37+
/**
38+
* Resolve the output channel for a response.
39+
*
40+
* - If the input channel is a system channel, route to "silent".
41+
* - If the channel is "silent" and an alias is configured, redirect to the alias.
42+
* - Otherwise, return the channel unchanged (echo back to sender).
43+
*/
44+
export function resolveOutputChannel(
45+
inputChannel: string,
46+
config: Pick<CortexConfig, "silentChannelAlias">,
47+
): string {
48+
const channel = SYSTEM_CHANNELS.has(inputChannel) ? "silent" : inputChannel;
49+
if (channel === "silent" && config.silentChannelAlias) {
50+
return config.silentChannelAlias;
51+
}
52+
return channel;
53+
}
54+
55+
// --- Combined registry ---
56+
57+
/**
58+
* Create a unified SkillRegistry that dispatches to built-in tools first,
59+
* then falls through to the external skill registry.
60+
*
61+
* Built-in tools receive a BuiltinToolContext (topicKey, etc.) instead of
62+
* the SkillRuntimeContext used by external skills.
63+
*
64+
* @param builtinTools Array of built-in tool definitions + executors.
65+
* @param skillRegistry External skill registry loaded from skillDirs.
66+
* @param getContext Closure that returns the current per-message context.
67+
*/
68+
export function createCombinedRegistry(
69+
builtinTools: BuiltinTool[],
70+
skillRegistry: SkillRegistry,
71+
getContext: () => BuiltinToolContext,
72+
): SkillRegistry {
73+
const builtinMap = new Map(builtinTools.map((t) => [t.definition.name, t]));
74+
75+
const allTools: ReadonlyArray<ToolDefinition> = [
76+
...builtinTools.map((t) => t.definition),
77+
...skillRegistry.tools,
78+
];
79+
80+
return {
81+
tools: allTools,
82+
83+
async executeTool(name, argumentsJson, ctx) {
84+
const builtin = builtinMap.get(name);
85+
if (builtin) {
86+
return builtin.execute(argumentsJson, getContext());
87+
}
88+
return skillRegistry.executeTool(name, argumentsJson, ctx);
89+
},
90+
91+
isMutating(name) {
92+
const builtin = builtinMap.get(name);
93+
if (builtin) return builtin.definition.mutatesState;
94+
return skillRegistry.isMutating(name);
95+
},
96+
};
97+
}

src/tools/send-message.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* send_message built-in tool.
3+
*
4+
* Allows the agent to proactively send a message to the user on a
5+
* specific channel. Validates the channel exists and can deliver,
6+
* then writes to the outbox for async delivery.
7+
*/
8+
9+
import { err, ok } from "@shetty4l/core/result";
10+
import type { ChannelRegistry } from "../channels";
11+
import { enqueueOutboxMessage } from "../db";
12+
import type { BuiltinTool, BuiltinToolContext } from "./index";
13+
14+
export function createSendMessageTool(
15+
channelRegistry: ChannelRegistry,
16+
): BuiltinTool {
17+
return {
18+
definition: {
19+
name: "send_message",
20+
description:
21+
"Send a message to the user on a specific channel (e.g. 'telegram'). Use this to proactively share information.",
22+
inputSchema: {
23+
type: "object",
24+
properties: {
25+
channel: {
26+
type: "string",
27+
description: "Target delivery channel (e.g. 'telegram')",
28+
},
29+
text: {
30+
type: "string",
31+
description: "Message text to send",
32+
},
33+
},
34+
required: ["channel", "text"],
35+
},
36+
mutatesState: true,
37+
},
38+
39+
async execute(argsJson: string, ctx: BuiltinToolContext) {
40+
const args = JSON.parse(argsJson) as {
41+
channel?: string;
42+
text?: string;
43+
};
44+
45+
if (!args.channel || typeof args.channel !== "string") {
46+
return err("channel is required and must be a string");
47+
}
48+
if (!args.text || typeof args.text !== "string") {
49+
return err("text is required and must be a string");
50+
}
51+
52+
// Validate channel exists and can deliver
53+
const channel = channelRegistry.get(args.channel);
54+
if (!channel) {
55+
return err(`unknown channel: '${args.channel}'`);
56+
}
57+
if (!channel.canDeliver) {
58+
return err(`channel '${args.channel}' cannot deliver messages`);
59+
}
60+
61+
enqueueOutboxMessage({
62+
channel: args.channel,
63+
topicKey: ctx.topicKey,
64+
text: args.text,
65+
});
66+
67+
return ok({
68+
content: `Message queued for delivery on ${args.channel}`,
69+
});
70+
},
71+
};
72+
}

0 commit comments

Comments
 (0)