diff --git a/eval/agent-eval.ts b/eval/agent-eval.ts index b7b0c8c1..21fbf418 100644 --- a/eval/agent-eval.ts +++ b/eval/agent-eval.ts @@ -859,7 +859,7 @@ async function runQuickFixWiring(): Promise { "[quickfix] user_model value round-trips as an object after re-upsert (no double-encode)", typeof aVal?.value === "object" && aVal?.value !== null && - (aVal?.value as Record).value === "ship fast", + (aVal?.value as Record | undefined)?.value === "ship fast", ); if (!KEEP) { @@ -1202,7 +1202,7 @@ async function runAutoLinkerGuard(): Promise { // another user's contacts. const A = "eval-autolink-a"; const B = "eval-autolink-b"; - const a1 = await resolveContact(A, "slack", "U_A1", "Alice", "dup@example.com"); + await resolveContact(A, "slack", "U_A1", "Alice", "dup@example.com"); await resolveContact(A, "email", "alice@work", "Alice", "dup@example.com"); const b1 = await resolveContact(B, "slack", "U_B1", "Alice", "dup@example.com"); await resolveContact(B, "email", "alice@work", "Alice", "dup@example.com"); @@ -1241,7 +1241,7 @@ async function runAutoLinkerGuard(): Promise { "[identity] contact_identities.metadata round-trips as an object (not double-encoded)", typeof ciRow?.metadata === "object" && ciRow?.metadata !== null && - (ciRow?.metadata as Record).handle === "u-meta", + (ciRow?.metadata as Record | undefined)?.handle === "u-meta", ); // Contact enrichment on resolution: a job title in the inbound metadata lands on @@ -1343,12 +1343,13 @@ async function runStyleProfiles(): Promise { "[style] profile round-trips as a jsonb object (not a double-encoded string)", typeof rowA?.profile === "object" && rowA?.profile !== null && - (rowA?.profile as Record).formality === 1, + (rowA?.profile as Record | undefined)?.formality === 1, ); check( "[style] B's global profile is its own (per-user scoped)", (await getStyleProfile(B, "global"))?.profile && - ((await getStyleProfile(B, "global"))?.profile as Record).formality === 5, + ((await getStyleProfile(B, "global"))?.profile as Record | undefined) + ?.formality === 5, ); // Re-upsert A: the unique key is (user_id, scope), so it updates in place. await upsertStyleProfile(A, null, "global", mk(2), 11); @@ -2034,7 +2035,7 @@ async function runAutoDreamDeep(): Promise { // ── Phase 2: near-duplicate merge on real 768-d embeddings ── const U = "eval-dream-merge"; const vec = (second: number) => { - const v = new Array(768).fill(0); + const v = Array.from({ length: 768 }, () => 0); v[0] = 1; v[1] = second; return v; diff --git a/eval/feature-manifest.ts b/eval/feature-manifest.ts index b704ef92..eefab825 100644 --- a/eval/feature-manifest.ts +++ b/eval/feature-manifest.ts @@ -510,7 +510,7 @@ export const FEATURES: FeatureSpec[] = [ { id: "autonomous-loops", summary: - "Load bundled LOOP.md definitions (3-tier: bundled/personal/project) and seed them into cron_jobs at boot (disabled by default). The agent authors + manages its OWN loops in-loop via the nomos-loops tools (source='agent'); the user audits/disables them in Settings.", + "Load bundled LOOP.md definitions (3-tier: bundled/personal/project) and seed them into cron_jobs at boot (disabled by default). The agent authors + manages its OWN loops in-loop via the nomos-loops tools (source='loop' — distinct from a user/assistant 'agent' TASK, so loops show on the Loops surface, not Tasks/Today); the user audits/disables them in Settings.", trigger: { kind: "boot" }, entry: ["seedAutonomousLoops", "loadAllLoops", "buildLoopMcpServer"], effects: [ @@ -533,7 +533,7 @@ export const FEATURES: FeatureSpec[] = [ invariants: [ "seeded idempotently (INSERT ON CONFLICT (name) DO NOTHING)", "bundled loops ship enabled:false; only the user or the agent enables them", - "agent-created loops carry source='agent' + the owner's user_id (auditable + per-owner scoped)", + "agent-created loops carry source='loop' + the owner's user_id (auditable + per-owner scoped; excluded from Tasks/Today, surfaced on Loops)", ], }, { diff --git a/scripts/studio-wire-check.ts b/scripts/studio-wire-check.ts index 741753eb..b6cc77f1 100644 --- a/scripts/studio-wire-check.ts +++ b/scripts/studio-wire-check.ts @@ -14,7 +14,7 @@ import "dotenv/config"; import { randomUUID } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import * as grpc from "@grpc/grpc-js"; diff --git a/src/auth/gws-accounts.ts b/src/auth/gws-accounts.ts index d4b26b8e..aff7a56b 100644 --- a/src/auth/gws-accounts.ts +++ b/src/auth/gws-accounts.ts @@ -231,7 +231,7 @@ export async function runGws( const env = { ...process.env, ...envForAccount(email), - ...(options.env ?? {}), + ...options.env, }; try { @@ -257,7 +257,7 @@ export async function runGwsJson( options: RunGwsOptions = {}, ): Promise { const { stdout } = await runGws(email, args, options); - const jsonStart = stdout.search(/[\[{]/); + const jsonStart = stdout.search(/[[{]/); if (jsonStart < 0) { throw new Error(`gws ${args.join(" ")} returned no JSON output`); } diff --git a/src/config/profile.ts b/src/config/profile.ts index eda5ec4c..3aad82b1 100644 --- a/src/config/profile.ts +++ b/src/config/profile.ts @@ -319,7 +319,8 @@ When the user asks for recurring actions (e.g. "check my emails every 15 minutes sections.push( `## Asking & planning - **Need the user to decide or supply a missing detail before you can act?** ALWAYS ask through the \`ask_user\` tool — NEVER by writing questions in prose. It shows them tappable choices. Ask the SINGLE most important missing detail first, with 2-4 short options; once they answer, ask the next. Even when several things are unknown (e.g. "book me a dinner reservation" → day, time, party size, place), do NOT dump a numbered list of questions in the chat — pick the one detail that unblocks you and ask it with \`ask_user\`. Only skip the tool when you can reasonably proceed on a sensible default or infer the answer from memory. -- **Laying out a multi-step plan?** Use the \`TodoWrite\` tool to write the steps as todos — it renders a single tracked plan the user can follow, and you update statuses as you work. Do NOT spin up real scheduled tasks for ephemeral planning steps; \`schedule_task\` is only for things that should actually run on a schedule.`, +- **Laying out a multi-step plan?** Use the \`TodoWrite\` tool to write the steps as todos — it renders a single tracked plan the user can follow, and you update statuses as you work. Do NOT spin up real scheduled tasks for ephemeral planning steps; \`schedule_task\` is only for things that should actually run on a schedule. +- **Never fake-load a tool.** Do NOT run a shell command, \`echo\`, or any placeholder to "load" or "prepare" a tool — your tools are already available; just call them. If you genuinely can't see a tool, locate it with ToolSearch and then call it. (\`ask_user\` in particular is always available — call it directly.)`, ); // Permissions diff --git a/src/cron/loop-view.ts b/src/cron/loop-view.ts index bf71b7dd..ceedb1c6 100644 --- a/src/cron/loop-view.ts +++ b/src/cron/loop-view.ts @@ -10,6 +10,7 @@ import type { CronJob } from "./types.ts"; import { prettifySchedule } from "./schedule-format.ts"; +import { prettifyTaskName } from "./task-view.ts"; export { prettifySchedule }; @@ -71,3 +72,15 @@ export function curateConsumerLoops(system: CronJob[], optedOut: Set): C ) .sort((a, b) => a.name.localeCompare(b.name)); } + +/** + * The owner's OWN agent-authored loops (source 'loop' -- created via loop_create), + * surfaced on the Loops page alongside the managed system loops, under their real + * (prettified) names. These are excluded from Tasks/Today (INFRA_SOURCES), so the + * Loops surface is where the user audits + toggles them. + */ +export function curateOwnedLoops(loops: CronJob[]): ConsumerLoop[] { + return loops + .map((j) => toWire(j, { name: prettifyTaskName(j.name), source: "loop" })) + .sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/src/cron/task-view.ts b/src/cron/task-view.ts index e00988c7..80d993d5 100644 --- a/src/cron/task-view.ts +++ b/src/cron/task-view.ts @@ -57,7 +57,7 @@ export interface ConsumerTask { * Tasks = what the user or the assistant (`source: "agent"`/`"user"`) scheduled; * the curated infra loops live on the Loops surface (see cron/loop-view.ts). */ -const INFRA_SOURCES = new Set(["system", "bundled"]); +const INFRA_SOURCES = new Set(["system", "bundled", "loop"]); export function toConsumerTask(j: CronJob): ConsumerTask { return { diff --git a/src/cron/types.ts b/src/cron/types.ts index b3f1022d..d6afbfbe 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -4,8 +4,8 @@ export type SessionTarget = "main" | "isolated"; export type DeliveryMode = "none" | "announce"; -/** Provenance: system (infra crons) | bundled (LOOP.md examples) | user (CLI/UI) | agent (self-authored). */ -export type CronJobSource = "system" | "bundled" | "user" | "agent"; +/** Provenance: system (infra crons) | bundled (LOOP.md examples) | user (CLI/UI) | agent (self-authored TASK) | loop (self-authored LOOP — recurring background behavior, shown on the Loops surface, not Tasks/Today). */ +export type CronJobSource = "system" | "bundled" | "user" | "agent" | "loop"; export interface CronJob { id: string; diff --git a/src/daemon/agent-runtime.ts b/src/daemon/agent-runtime.ts index c99b4981..540d942c 100644 --- a/src/daemon/agent-runtime.ts +++ b/src/daemon/agent-runtime.ts @@ -59,6 +59,8 @@ function getDisallowedTools(): string[] { // the daemon and never show in the user's settings). A prompt warning alone didn't // stop the agent from reaching for them, so block them outright → the agent must use // the `schedule_task` / `loop_create` MCP tools, which run locally in the daemon. + // - `AskUserQuestion` is the SDK's native ask tool; it bypasses Nomos's + // elicitation (so no Ask card renders in the app) → use the `ask_user` MCP tool. const blocked: string[] = [ "Workflow", "TaskCreate", @@ -70,6 +72,7 @@ function getDisallowedTools(): string[] { "CronList", "RemoteTrigger", "ScheduleWakeup", + "AskUserQuestion", ]; if (!FEATURES.bashTool()) { blocked.push("Bash", "BashOutput", "KillBash"); @@ -1068,12 +1071,26 @@ export class AgentRuntime { // channel sender id to the canonical local id (otherwise the vault fragments // per channel); in hosted mode this is the authenticated per-tenant user. const vaultUserId = resolveMemoryUserId(userId); + // Elicitation for the in-process `ask_user` tool. The SDK does NOT forward + // `elicitation/create` from in-process MCP servers (it answers -32601 Method + // not found), so hand the tool a direct callback into the ElicitationManager + // instead of relying on the SDK's `extra.sendRequest`. + const mgr = this.elicitationManager; + const elicit = + mgr && source + ? (request: unknown, opts: { signal?: AbortSignal }) => + mgr.handleElicitation( + request as Parameters[0], + source, + opts.signal ?? new AbortController().signal, + ) + : undefined; let mcpServers = { ...this.mcpServers, "nomos-vault": buildVaultMcpServer(vaultUserId), // Rebuild the memory tools per-turn so memory_search is scoped to this // owner (the cached one at init has no user). Overrides the cached entry. - "nomos-memory": createMemoryMcpServer(vaultUserId), + "nomos-memory": createMemoryMcpServer(vaultUserId, { elicit }), // Loop self-management, scoped to this owner so loops the agent creates are // owned by (and auditable by) the right user. The cron engine runs in this // process; block self-replication when this turn is itself a loop fire. @@ -1251,10 +1268,10 @@ export class AgentRuntime { } // Build the elicitation callback for this turn. The `ask_user` MCP - // tool calls `extra.sendRequest({method: "elicitation/create"})`; - // the SDK forwards to `onElicitation`, we route to the channel the - // user is currently talking to us on, and return their answer. - const mgr = this.elicitationManager; + // tool calls `extra.sendRequest({method: "elicitation/create"})`; for external + // MCP servers the SDK forwards to `onElicitation` (in-process servers go through + // the `elicit` callback wired into nomos-memory above). We route to the channel + // the user is talking to us on and return their answer. `mgr` is declared above. const onElicitation: import("../sdk/session.ts").RunSessionParams["onElicitation"] = mgr && source ? (request, opts) => mgr.handleElicitation(request, source, opts.signal) diff --git a/src/daemon/grpc-server.ts b/src/daemon/grpc-server.ts index 079d7570..8f2fcdbc 100644 --- a/src/daemon/grpc-server.ts +++ b/src/daemon/grpc-server.ts @@ -306,20 +306,21 @@ export class GrpcServer { ): Promise { try { const { CronStore } = await import("../cron/store.ts"); - const jobs = await new CronStore().listJobs({ userId: "local" }); + const { curateConsumerLoops, curateOwnedLoops, MANAGED_LOOPS } = + await import("../cron/loop-view.ts"); + const { isLoopUserDisabled } = await import("../cron/loop-overrides.ts"); + // Loops = the managed system loops + the agent's own self-authored loops + // (source 'loop'). The user's scheduled TASKS live on the Tasks surface, so + // curate rather than dumping every cron_jobs row here. + const store = new CronStore(); + const system = await store.listJobs({ userId: "local" }); + const owned = await store.listJobs({ userId: "local", source: "loop" }); + const optedOut = new Set(); + for (const j of system) { + if (MANAGED_LOOPS[j.name] && (await isLoopUserDisabled(j.name))) optedOut.add(j.name); + } callback(null, { - loops: jobs - .sort((a, b) => a.name.localeCompare(b.name)) - .map((j) => ({ - id: j.id, - name: j.name, - schedule: j.schedule, - enabled: j.enabled, - source: j.source ?? "system", - errorCount: j.errorCount, - lastRun: j.lastRun ? j.lastRun.toISOString() : "", - prompt: j.prompt, - })), + loops: [...curateConsumerLoops(system, optedOut), ...curateOwnedLoops(owned)], }); } catch (err) { callback(err as grpc.ServiceError, null); diff --git a/src/daemon/mobile-api.ts b/src/daemon/mobile-api.ts index 56472ca6..90f1a288 100644 --- a/src/daemon/mobile-api.ts +++ b/src/daemon/mobile-api.ts @@ -30,7 +30,6 @@ import { googleRedirectUri, GOOGLE_SCOPES, isGoogleIntegrationConfigured, - listGoogleAccounts, removeGoogleAccount, setSendEnabled, signOAuthState, @@ -54,6 +53,7 @@ import { CronStore } from "../cron/store.ts"; import { isLoopUserDisabled, setLoopUserEnabled } from "../cron/loop-overrides.ts"; import { curateConsumerLoops, + curateOwnedLoops, MANAGED_LOOPS, MANAGED_LABEL_TO_NAME, type ConsumerLoop, @@ -648,7 +648,7 @@ async function handleGetEarnings(): Promise<{ bondsCount: 0, avgBondCents: 0, acceptRatePct: 0, - seriesCents: new Array(14).fill(0), + seriesCents: Array.from({ length: 14 }, () => 0), }; } @@ -1062,10 +1062,13 @@ async function handleDeleteVaultNote( // the Proactive setting) are hidden. User/agent-created loops show under their // real name and toggle the row directly. -async function handleListLoops(_ctx: TenantContext): Promise<{ loops: ConsumerLoop[] }> { - // Loops = the assistant's always-on background behaviors (owned by the `system` - // tenant). The user's own scheduled jobs live on the Tasks surface, not here. - const system = await new CronStore().listJobs({ userId: systemTenant().userId }); +async function handleListLoops(ctx: TenantContext): Promise<{ loops: ConsumerLoop[] }> { + // Loops = the assistant's always-on background behaviors: the managed `system` + // loops PLUS the agent's own self-authored loops (source 'loop'). The user's + // scheduled TASKS (source 'agent'/'user') live on the Tasks surface, not here. + const store = new CronStore(); + const system = await store.listJobs({ userId: systemTenant().userId }); + const owned = await store.listJobs({ userId: resolveMemoryUserId(ctx.userId), source: "loop" }); // Which managed loops the user has turned off (folded into `enabled`). const optedOut = new Set(); @@ -1073,7 +1076,7 @@ async function handleListLoops(_ctx: TenantContext): Promise<{ loops: ConsumerLo if (MANAGED_LOOPS[j.name] && (await isLoopUserDisabled(j.name))) optedOut.add(j.name); } - return { loops: curateConsumerLoops(system, optedOut) }; + return { loops: [...curateConsumerLoops(system, optedOut), ...curateOwnedLoops(owned)] }; } async function handleSetLoopEnabled( diff --git a/src/db/schema.sql b/src/db/schema.sql index dc7946c3..fb07db89 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -660,6 +660,13 @@ BEGIN CREATE INDEX IF NOT EXISTS idx_cron_source ON cron_jobs(source); END IF; + -- Agent-authored LOOPS carry a distinct `loop` source so they surface on the Loops + -- page, not the Tasks/Today surfaces (which are user/assistant scheduled `agent` + -- tasks). Widen the source CHECK -- a superset, so all existing rows stay valid. + ALTER TABLE cron_jobs DROP CONSTRAINT IF EXISTS cron_jobs_source_check; + ALTER TABLE cron_jobs ADD CONSTRAINT cron_jobs_source_check + CHECK (source IN ('system', 'bundled', 'user', 'agent', 'loop')); + -- slack_user_tokens IF NOT EXISTS ( SELECT 1 FROM information_schema.columns diff --git a/src/db/types.ts b/src/db/types.ts index eb126404..62c2dc36 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -83,8 +83,8 @@ export interface CronJobsTable { last_run: ColumnType; last_error: string | null; created_at: Generated; - /** Provenance: system (infra crons), bundled (LOOP.md examples), user (CLI/UI), agent (self-authored). */ - source: Generated<"system" | "bundled" | "user" | "agent">; + /** Provenance: system (infra crons), bundled (LOOP.md examples), user (CLI/UI), agent (self-authored TASK), loop (self-authored LOOP — Loops surface, not Tasks/Today). */ + source: Generated<"system" | "bundled" | "user" | "agent" | "loop">; } export interface CronRunsTable { diff --git a/src/memory/knowledge-compiler.ts b/src/memory/knowledge-compiler.ts index 5a9486bb..aea7c3fd 100644 --- a/src/memory/knowledge-compiler.ts +++ b/src/memory/knowledge-compiler.ts @@ -202,7 +202,7 @@ export async function compileKnowledge(options?: { // Resolve settings from config (DB > env > defaults), then apply any override. const wiki: ResolvedWikiConfig = { ...resolveWiki(await loadEnvConfigAsync()), - ...(options?.wikiConfig ?? {}), + ...options?.wikiConfig, }; // Hard off-switch: a disabled wiki does no work, on every path (cron/CLI/eval). @@ -392,7 +392,6 @@ Maximum ${wiki.maxArticles} articles. Return [] if nothing is worth compiling.`, // + MOC topic hubs). try { const { syncWikiBodyLinks, syncWikiMOCs } = await import("./graph-writer.ts"); - const { LOCAL_TENANT } = await import("../auth/tenant-context.ts"); await syncWikiBodyLinks({ orgId: process.env.NOMOS_ORG_ID ?? "local", userId }); await syncWikiMOCs({ orgId: process.env.NOMOS_ORG_ID ?? "local", userId }); } catch (err) { diff --git a/src/sdk/ask-user.ts b/src/sdk/ask-user.ts index 46cf9732..0601bd1d 100644 --- a/src/sdk/ask-user.ts +++ b/src/sdk/ask-user.ts @@ -40,6 +40,17 @@ export interface AskUserToolOptions { * `ToolConfig.askUserQuestion.previewFormat`. */ previewFormat?: "markdown" | "html"; + /** + * Host elicitation callback. When provided, `ask_user` routes the question + * through this (which calls the ElicitationManager directly) INSTEAD of the + * SDK's `extra.sendRequest`. The SDK does not forward `elicitation/create` + * from in-process MCP servers — it answers `-32601 Method not found` — so for + * the daemon's own tools this direct callback is the only working path. + */ + elicit?: ( + request: unknown, + opts: { signal?: AbortSignal }, + ) => Promise<{ action: string; content?: Record }>; } /** @@ -49,7 +60,7 @@ export interface AskUserToolOptions { * accepts heterogeneous `SdkMcpToolDefinition<...>` values in its * tools array. */ -export function createAskUserTool(_options: AskUserToolOptions = {}) { +export function createAskUserTool(options: AskUserToolOptions = {}) { return tool( "ask_user", ASK_USER_DESCRIPTION, @@ -125,40 +136,47 @@ export function createAskUserTool(_options: AskUserToolOptions = {}) { // The Claude Agent SDK intercepts `elicitation/create` and routes // it to the host's `onElicitation` callback. try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const ext = extra as { - sendRequest?: ( - req: { method: string; params: unknown }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - resultSchema: any, - ) => Promise<{ action: string; content?: Record }>; - }; - if (!ext?.sendRequest) { - return { - content: [ - { - type: "text", - text: "ask_user: this MCP server is not connected to a host that supports elicitation; ask the user in plain prose instead.", - }, - ], - isError: true, + let result: { action: string; content?: Record }; + if (options.elicit) { + // Direct host elicitation (the ElicitationManager). The SDK does NOT + // forward elicitation/create from in-process MCP servers (-32601), so + // this is the working path for the daemon's own tools. + result = await options.elicit(requestPayload, { + signal: (extra as { signal?: AbortSignal } | undefined)?.signal, + }); + } else { + // Fallback: ask over MCP elicitation, for hosts that support it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ext = extra as { + sendRequest?: ( + req: { method: string; params: unknown }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resultSchema: any, + ) => Promise<{ action: string; content?: Record }>; }; + if (!ext?.sendRequest) { + return { + content: [ + { + type: "text", + text: "ask_user: this MCP server is not connected to a host that supports elicitation; ask the user in plain prose instead.", + }, + ], + isError: true, + }; + } + // Minimal zod result schema (the MCP SDK is only a transitive dep). The + // shape we accept is the same the ElicitationManager produces. + const resultSchema = z.object({ + action: z.enum(["accept", "decline", "cancel"]), + content: z.record(z.string(), z.unknown()).optional(), + }); + result = await ext.sendRequest( + { method: "elicitation/create", params: requestPayload }, + resultSchema, + ); } - // Build a minimal zod result schema rather than importing the MCP - // SDK's `ElicitResultSchema` directly (MCP SDK is only a - // transitive dep here). The shape we accept is the same one the - // ElicitationManager produces: `{action, content?}`. - const resultSchema = z.object({ - action: z.enum(["accept", "decline", "cancel"]), - content: z.record(z.string(), z.unknown()).optional(), - }); - - const result = await ext.sendRequest( - { method: "elicitation/create", params: requestPayload }, - resultSchema, - ); - if (result.action === "accept") { const answer = (result.content?.answer as string) ?? ""; if (!answer) { diff --git a/src/sdk/loop-mcp.ts b/src/sdk/loop-mcp.ts index b4c90800..e17aaedd 100644 --- a/src/sdk/loop-mcp.ts +++ b/src/sdk/loop-mcp.ts @@ -96,7 +96,7 @@ export function buildLoopMcpServer( try { // Only the agent's OWN loops are listable/manageable here -- system infra // jobs and user-authored loops are not the agent's to surface or toggle. - const jobs = await store.listJobs({ userId, source: "agent" }); + const jobs = await store.listJobs({ userId, source: "loop" }); if (jobs.length === 0) { return ok("No autonomous loops yet. Create one with loop_create."); } @@ -152,7 +152,7 @@ export function buildLoopMcpServer( const existing = await store.getJobByName(args.name); if (existing) return fail(`A loop named "${args.name}" already exists. Pick another name.`); - const agentLoops = (await store.listJobs({ userId, source: "agent" })).length; + const agentLoops = (await store.listJobs({ userId, source: "loop" })).length; if (agentLoops >= MAX_AGENT_LOOPS) { return fail( `You already have ${agentLoops} self-created loops (max ${MAX_AGENT_LOOPS}). Delete one with loop_delete first.`, @@ -170,7 +170,7 @@ export function buildLoopMcpServer( prompt: args.prompt, enabled, errorCount: 0, - source: "agent", + source: "loop", }); // Make the cron engine pick up the new job immediately. process.emit("cron:refresh" as never); @@ -279,6 +279,8 @@ export function buildLoopMcpServer( return createSdkMcpServer({ name: "nomos-loops", version: "1.0.0", + // Always loaded so loop_create/list/etc. are reachable every turn, not deferred. + alwaysLoad: true, tools: [loopList, loopCreate, loopEnable, loopDisable, loopUpdate, loopDelete], }); } diff --git a/src/sdk/team-mcp.ts b/src/sdk/team-mcp.ts index ca670f2b..b7e43081 100644 --- a/src/sdk/team-mcp.ts +++ b/src/sdk/team-mcp.ts @@ -81,6 +81,9 @@ export function buildTeamMcpServer(deps: TeamMcpDeps): McpSdkServerConfigWithIns return createSdkMcpServer({ name: "nomos-team", version: "0.1.0", + // Always in the prompt (never deferred behind tool search) so the agent calls + // delegate_to_team directly instead of improvising a "load" step. + alwaysLoad: true, tools: [delegateToTeam], }); } diff --git a/src/sdk/tools.ts b/src/sdk/tools.ts index 84b8b23a..439dfef8 100644 --- a/src/sdk/tools.ts +++ b/src/sdk/tools.ts @@ -7,7 +7,7 @@ import { z } from "zod/v4"; import { traceMemory } from "../memory/trace.ts"; import { handleBootstrapComplete } from "../ui/bootstrap.ts"; import { createLogger } from "../lib/logger.ts"; -import { createAskUserTool } from "./ask-user.ts"; +import { createAskUserTool, type AskUserToolOptions } from "./ask-user.ts"; const log = createLogger("sdk-tools"); import { @@ -52,7 +52,10 @@ function formatSubgraph(sub: Subgraph): string { * Creates an in-process MCP server that exposes memory tools to the agent. * The agent can call `memory_search` to query the pgvector-backed memory store. */ -export function createMemoryMcpServer(userId: string = "local"): McpSdkServerConfigWithInstance { +export function createMemoryMcpServer( + userId: string = "local", + opts: { elicit?: AskUserToolOptions["elicit"] } = {}, +): McpSdkServerConfigWithInstance { const memorySearchTool = tool( "memory_search", "Search the long-term memory store using hybrid vector + text search. Returns relevant code snippets, documentation, and previously stored knowledge. Use the category filter for targeted recall.", @@ -1350,7 +1353,7 @@ export function createMemoryMcpServer(userId: string = "local"): McpSdkServerCon // Multi-choice user prompt that routes through MCP elicitation. The // actual rendering happens host-side in `src/daemon/elicitation-manager.ts`. - const askUserTool = createAskUserTool(); + const askUserTool = createAskUserTool({ elicit: opts.elicit }); const proposePlanTool = tool( "propose_plan", @@ -2211,6 +2214,10 @@ export function createMemoryMcpServer(userId: string = "local"): McpSdkServerCon return createSdkMcpServer({ name: "nomos-memory", version: "0.1.0", + // Always in the prompt (never deferred behind tool search) so the agent can call + // ask_user / schedule_task / memory_search directly — never improvising a "load" + // step (which it does for deferred tools, e.g. a fake `echo "Loading ..." `). + alwaysLoad: true, tools: [ memorySearchTool, userModelRecallTool, diff --git a/src/sdk/vault-mcp.ts b/src/sdk/vault-mcp.ts index 6c8771f6..6c5b6c89 100644 --- a/src/sdk/vault-mcp.ts +++ b/src/sdk/vault-mcp.ts @@ -167,6 +167,8 @@ export function buildVaultMcpServer(userId: string): McpSdkServerConfigWithInsta return createSdkMcpServer({ name: "nomos-vault", version: "1.0.0", + // Always loaded so durable memory read/write is reachable every turn, not deferred. + alwaysLoad: true, tools: [memoryRead, memoryWrite, memoryList, memoryForget, loadThread], }); }