Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions eval/agent-eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,7 @@ async function runQuickFixWiring(): Promise<void> {
"[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<string, unknown>).value === "ship fast",
(aVal?.value as Record<string, unknown> | undefined)?.value === "ship fast",
);

if (!KEEP) {
Expand Down Expand Up @@ -1202,7 +1202,7 @@ async function runAutoLinkerGuard(): Promise<void> {
// 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");
Expand Down Expand Up @@ -1241,7 +1241,7 @@ async function runAutoLinkerGuard(): Promise<void> {
"[identity] contact_identities.metadata round-trips as an object (not double-encoded)",
typeof ciRow?.metadata === "object" &&
ciRow?.metadata !== null &&
(ciRow?.metadata as Record<string, unknown>).handle === "u-meta",
(ciRow?.metadata as Record<string, unknown> | undefined)?.handle === "u-meta",
);

// Contact enrichment on resolution: a job title in the inbound metadata lands on
Expand Down Expand Up @@ -1343,12 +1343,13 @@ async function runStyleProfiles(): Promise<void> {
"[style] profile round-trips as a jsonb object (not a double-encoded string)",
typeof rowA?.profile === "object" &&
rowA?.profile !== null &&
(rowA?.profile as Record<string, unknown>).formality === 1,
(rowA?.profile as Record<string, unknown> | 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<string, unknown>).formality === 5,
((await getStyleProfile(B, "global"))?.profile as Record<string, unknown> | 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);
Expand Down Expand Up @@ -2034,7 +2035,7 @@ async function runAutoDreamDeep(): Promise<void> {
// ── 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;
Expand Down
4 changes: 2 additions & 2 deletions eval/feature-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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)",
],
},
{
Expand Down
2 changes: 1 addition & 1 deletion scripts/studio-wire-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 2 additions & 2 deletions src/auth/gws-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export async function runGws(
const env = {
...process.env,
...envForAccount(email),
...(options.env ?? {}),
...options.env,
};

try {
Expand All @@ -257,7 +257,7 @@ export async function runGwsJson<T = unknown>(
options: RunGwsOptions = {},
): Promise<T> {
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`);
}
Expand Down
3 changes: 2 additions & 1 deletion src/config/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/cron/loop-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import type { CronJob } from "./types.ts";
import { prettifySchedule } from "./schedule-format.ts";
import { prettifyTaskName } from "./task-view.ts";

export { prettifySchedule };

Expand Down Expand Up @@ -71,3 +72,15 @@ export function curateConsumerLoops(system: CronJob[], optedOut: Set<string>): 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));
}
2 changes: 1 addition & 1 deletion src/cron/task-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/cron/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 22 additions & 5 deletions src/daemon/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -70,6 +72,7 @@ function getDisallowedTools(): string[] {
"CronList",
"RemoteTrigger",
"ScheduleWakeup",
"AskUserQuestion",
];
if (!FEATURES.bashTool()) {
blocked.push("Bash", "BashOutput", "KillBash");
Expand Down Expand Up @@ -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<typeof mgr.handleElicitation>[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.
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 14 additions & 13 deletions src/daemon/grpc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,20 +306,21 @@ export class GrpcServer {
): Promise<void> {
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<string>();
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);
Expand Down
17 changes: 10 additions & 7 deletions src/daemon/mobile-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
googleRedirectUri,
GOOGLE_SCOPES,
isGoogleIntegrationConfigured,
listGoogleAccounts,
removeGoogleAccount,
setSendEnabled,
signOAuthState,
Expand All @@ -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,
Expand Down Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -1062,18 +1062,21 @@ 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<string>();
for (const j of system) {
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(
Expand Down
7 changes: 7 additions & 0 deletions src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ export interface CronJobsTable {
last_run: ColumnType<Date | null, Date | null, Date | null>;
last_error: string | null;
created_at: Generated<Date>;
/** 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 {
Expand Down
3 changes: 1 addition & 2 deletions src/memory/knowledge-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading