From 9fe6b920c4ef3e90a9c795e94f077bef23698dd9 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Fri, 6 Feb 2026 22:23:44 +0800 Subject: [PATCH 1/6] fix(agent): always provide getApiKey callback and emit error events PiAgentCore was created with an empty object when no API key was initially configured. This broke dynamic provider switching because setProvider() updated currentApiKey but PiAgentCore had no getApiKey callback to read it. Always provide the callback so it dynamically reads the current key. Also adds AgentErrorEvent to MulticaEvent and emits it from AsyncAgent.write() catch handlers so errors flow through the subscriber mechanism to IPC listeners. Co-Authored-By: Claude Opus 4.6 --- src/agent/async-agent.ts | 3 +++ src/agent/events.ts | 12 +++++++++--- src/agent/runner.ts | 25 +++++++++++++++++-------- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index c1eb9a5e..c1b30ea4 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -51,11 +51,14 @@ export class AsyncAgent { // Normal text is delivered via message_end event; only handle errors here if (result.error) { this.channel.send({ id: uuidv7(), content: `[error] ${result.error}` }); + this.agent.emitError(result.error); } }) .catch((err) => { const message = err instanceof Error ? err.message : String(err); this.channel.send({ id: uuidv7(), content: `[error] ${message}` }); + // Also emit through subscriber mechanism so IPC listeners receive the error + this.agent.emitError(message); }) .finally(() => { this.pendingWrites = Math.max(0, this.pendingWrites - 1); diff --git a/src/agent/events.ts b/src/agent/events.ts index 8eb8b422..3ae35b64 100644 --- a/src/agent/events.ts +++ b/src/agent/events.ts @@ -21,10 +21,16 @@ export type CompactionEndEvent = { type: "compaction_end"; removed: number; kept: number; - tokensRemoved?: number; - tokensKept?: number; + tokensRemoved?: number | undefined; + tokensKept?: number | undefined; reason: "count" | "tokens" | "summary" | "pruning"; }; +/** Emitted when an agent encounters an error during execution */ +export type AgentErrorEvent = { + type: "agent_error"; + message: string; +}; + /** Union of all Multica-specific events */ -export type MulticaEvent = CompactionStartEvent | CompactionEndEvent; +export type MulticaEvent = CompactionStartEvent | CompactionEndEvent | AgentErrorEvent; diff --git a/src/agent/runner.ts b/src/agent/runner.ts index d364f0fa..c6fa4566 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -1,7 +1,7 @@ import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core"; import { v7 as uuidv7 } from "uuid"; import type { AgentOptions, AgentRunResult, ReasoningMode } from "./types.js"; -import type { MulticaEvent } from "./events.js"; +import type { MulticaEvent, CompactionEndEvent } from "./events.js"; import { createAgentOutput } from "./cli/output.js"; import { resolveModel, resolveTools, type ResolveToolsOptions } from "./tools.js"; import { @@ -159,11 +159,14 @@ export class Agent { : 0; } - this.agent = new PiAgentCore( - this.currentApiKey - ? { getApiKey: (_provider: string) => this.currentApiKey! } - : {}, - ); + this.agent = new PiAgentCore({ + getApiKey: (_provider: string) => { + if (!this.currentApiKey) { + throw new Error(`No API key configured for provider: ${this.resolvedProvider}`); + } + return this.currentApiKey; + }, + }); // Load Agent Profile (if profileId is specified) // Every Agent should have a Profile for memory, tools config, and other settings @@ -352,6 +355,11 @@ export class Agent { } } + /** Emit an error event through the subscriber mechanism */ + emitError(message: string): void { + this.emitMulticaEvent({ type: "agent_error", message }); + } + async run(prompt: string): Promise { await this.ensureInitialized(); this.output.state.lastAssistantText = ""; @@ -461,14 +469,15 @@ export class Agent { if (result?.kept) { this.agent.replaceMessages(result.kept); } - this.emitMulticaEvent({ + const endEvent: CompactionEndEvent = { type: "compaction_end", removed: result?.removedCount ?? 0, kept: result?.kept.length ?? messages.length, tokensRemoved: result?.tokensRemoved, tokensKept: result?.tokensKept, reason: result?.reason ?? "tokens", - }); + }; + this.emitMulticaEvent(endEvent); } catch (err) { throw err; } From 0e383f51ab7785363402d36d517d766c7bc0ae49 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Fri, 6 Feb 2026 22:23:53 +0800 Subject: [PATCH 2/6] feat(desktop): display agent errors in Chat UI instead of hanging When the agent encounters an error (e.g. no API key configured), the Chat UI now shows an error banner instead of silently hanging. The user can still type and retry after fixing their configuration. - Add AgentErrorEvent to SDK stream types - Forward agent_error events through IPC to renderer - Handle error events in useLocalChat hook - Keep chat input enabled for AGENT_ERROR (retriable) Co-Authored-By: Claude Opus 4.6 --- apps/desktop/electron/ipc/hub.ts | 11 +++++++++++ apps/desktop/src/hooks/use-local-chat.ts | 9 +++++++++ packages/sdk/src/actions/index.ts | 1 + packages/sdk/src/actions/stream.ts | 10 ++++++++-- packages/ui/src/components/chat-view.tsx | 4 ++-- 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/desktop/electron/ipc/hub.ts b/apps/desktop/electron/ipc/hub.ts index b038efe8..ca972cef 100644 --- a/apps/desktop/electron/ipc/hub.ts +++ b/apps/desktop/electron/ipc/hub.ts @@ -281,6 +281,17 @@ export function registerHubIpcHandlers(): void { return } + // Agent error events: forward so the UI can display them + if (event.type === 'agent_error') { + safeLog(`[IPC] Sending agent_error event to renderer: ${(event as { message: string }).message}`) + mainWindowRef.webContents.send('localChat:event', { + agentId, + streamId: null, + event, + }) + return + } + // Filter events same as Hub.consumeAgent() const maybeMessage = (event as { message?: { role?: string } }).message const isAssistantMessage = maybeMessage?.role === 'assistant' diff --git a/apps/desktop/src/hooks/use-local-chat.ts b/apps/desktop/src/hooks/use-local-chat.ts index cf637f25..c6353e12 100644 --- a/apps/desktop/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/hooks/use-local-chat.ts @@ -5,6 +5,7 @@ import type { ExecApprovalRequestPayload, ApprovalDecision, AgentMessageItem, + AgentErrorEvent, } from '@multica/sdk' import { DEFAULT_MESSAGES_LIMIT } from '@multica/sdk' @@ -56,6 +57,14 @@ export function useLocalChat() { const payload = data as unknown as StreamPayload if (!payload.event) return + // Handle agent error events + if (payload.event.type === 'agent_error') { + const errorEvent = payload.event as AgentErrorEvent + chatRef.current.setError({ code: 'AGENT_ERROR', message: errorEvent.message }) + setIsLoading(false) + return + } + chatRef.current.handleStream(payload) if (payload.event.type === 'message_start') setIsLoading(true) if (payload.event.type === 'message_end') setIsLoading(false) diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index c378a6d6..b3ef94e3 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -38,6 +38,7 @@ export { type CompactionEvent, type CompactionStartEvent, type CompactionEndEvent, + type AgentErrorEvent, type ContentBlock, type TextContent, type ThinkingContent, diff --git a/packages/sdk/src/actions/stream.ts b/packages/sdk/src/actions/stream.ts index c0249353..dfaf06fa 100644 --- a/packages/sdk/src/actions/stream.ts +++ b/packages/sdk/src/actions/stream.ts @@ -45,16 +45,22 @@ export type CompactionEndEvent = { /** Union of all compaction events */ export type CompactionEvent = CompactionStartEvent | CompactionEndEvent; +/** Emitted when an agent encounters an error during execution */ +export type AgentErrorEvent = { + type: "agent_error"; + message: string; +}; + // --- Stream event types --- /** - * Hub forwards AgentEvent from pi-agent-core and CompactionEvent as-is. + * Hub forwards AgentEvent from pi-agent-core, CompactionEvent, and AgentErrorEvent as-is. * StreamPayload wraps them with routing metadata. */ export interface StreamPayload { streamId: string; agentId: string; - event: AgentEvent | CompactionEvent; + event: AgentEvent | CompactionEvent | AgentErrorEvent; } /** Extract thinking/reasoning content from an AgentEvent that carries a message */ diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx index 89c6217a..4582b7be 100644 --- a/packages/ui/src/components/chat-view.tsx +++ b/packages/ui/src/components/chat-view.tsx @@ -236,8 +236,8 @@ export function ChatView({
From 8d32a06b5c2b2ab3d10677f0edc708fd5d385a15 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 9 Feb 2026 13:51:45 +0800 Subject: [PATCH 3/6] fix(agent): validate API key before calling PiAgentCore.prompt() getApiKey errors thrown inside PiAgentCore's internal async context result in UnhandledPromiseRejection instead of propagating to the caller. Return a graceful error early so AsyncAgent can emit it through the subscriber mechanism to the UI. Co-Authored-By: Claude Opus 4.6 --- src/agent/runner.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/agent/runner.ts b/src/agent/runner.ts index c6fa4566..7d63bfe5 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -364,6 +364,14 @@ export class Agent { await this.ensureInitialized(); this.output.state.lastAssistantText = ""; + // Early validation: check API key before calling PiAgentCore.prompt(), + // because getApiKey errors thrown inside PiAgentCore's internal async + // context result in UnhandledPromiseRejection instead of propagating. + if (!this.currentApiKey) { + const errorMsg = `No API key configured for provider: ${this.resolvedProvider}. Please configure a provider in Agent Settings.`; + return { text: "", error: errorMsg }; + } + const canRotate = !this.pinnedProfile && this.profileCandidates.length > 1; let lastError: unknown; From ed681a96bfd14d53b1cbc7f874fd9f765442d396 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 9 Feb 2026 13:51:55 +0800 Subject: [PATCH 4/6] feat(desktop): add Configure button in chat error banner When the agent fails due to missing API key, the error banner now shows a "Configure" button that opens the same ApiKeyDialog (or OAuthDialog) used on the home page. After successful configuration the error clears and the user can immediately start chatting. Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/components/local-chat.tsx | 92 +++++++++++++++++++--- apps/desktop/src/hooks/use-local-chat.ts | 5 ++ packages/ui/src/components/chat-view.tsx | 35 +++++--- 3 files changed, 109 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/components/local-chat.tsx b/apps/desktop/src/components/local-chat.tsx index 5384c063..ade9db6c 100644 --- a/apps/desktop/src/components/local-chat.tsx +++ b/apps/desktop/src/components/local-chat.tsx @@ -1,6 +1,10 @@ +import { useState, useCallback } from 'react' import { Loading } from '@multica/ui/components/ui/loading' import { ChatView } from '@multica/ui/components/chat-view' import { useLocalChat } from '../hooks/use-local-chat' +import { useProvider } from '../hooks/use-provider' +import { ApiKeyDialog } from './api-key-dialog' +import { OAuthDialog } from './oauth-dialog' export function LocalChat() { const { @@ -17,8 +21,41 @@ export function LocalChat() { sendMessage, loadMore, resolveApproval, + clearError, } = useLocalChat() + const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProvider() + + // Provider config dialog state + const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) + const [oauthDialogOpen, setOauthDialogOpen] = useState(false) + + const handleConfigureProvider = useCallback(() => { + const providerId = current?.provider + if (!providerId) return + + const meta = providers.find((p) => p.id === providerId) + if (!meta) return + + if (meta.authMethod === 'oauth') { + setOauthDialogOpen(true) + } else { + setApiKeyDialogOpen(true) + } + }, [current, providers]) + + const handleProviderConfigSuccess = useCallback(async () => { + const providerId = current?.provider + if (!providerId) return + + await refreshProviders() + await switchProvider(providerId) + clearError() + }, [current, refreshProviders, switchProvider, clearError]) + + // Derive provider info for dialogs + const currentMeta = current ? providers.find((p) => p.id === current.provider) : null + if (initError) { return (
@@ -36,19 +73,48 @@ export function LocalChat() { ) } + // Show "Configure" button when error is about provider/API key + const errorAction = error?.code === 'AGENT_ERROR' && currentMeta + ? { label: 'Configure', onClick: handleConfigureProvider } + : undefined + return ( - + <> + + + {currentMeta && currentMeta.authMethod === 'api-key' && ( + + )} + + {currentMeta && currentMeta.authMethod === 'oauth' && ( + + )} + ) } diff --git a/apps/desktop/src/hooks/use-local-chat.ts b/apps/desktop/src/hooks/use-local-chat.ts index c6353e12..749ee071 100644 --- a/apps/desktop/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/hooks/use-local-chat.ts @@ -142,6 +142,10 @@ export function useLocalChat() { [], ) + const clearError = useCallback(() => { + chatRef.current.setError(null) + }, []) + return { agentId, initError, @@ -156,5 +160,6 @@ export function useLocalChat() { sendMessage, loadMore, resolveApproval, + clearError, } } diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx index 4582b7be..b47edf0b 100644 --- a/packages/ui/src/components/chat-view.tsx +++ b/packages/ui/src/components/chat-view.tsx @@ -38,6 +38,8 @@ export interface ChatViewProps { loadMore?: () => void; resolveApproval: (approvalId: string, decision: "allow-once" | "allow-always" | "deny") => void; onDisconnect?: () => void; + /** Optional action button in the error banner (e.g. "Configure Provider") */ + errorAction?: { label: string; onClick: () => void }; } export function ChatView({ @@ -53,6 +55,7 @@ export function ChatView({ loadMore, resolveApproval, onDisconnect, + errorAction, }: ChatViewProps) { const mainRef = useRef(null); const sentinelRef = useRef(null); @@ -219,16 +222,28 @@ export function ChatView({
{error.message} - {onDisconnect && ( - - )} +
+ {errorAction && ( + + )} + {onDisconnect && ( + + )} +
)} From 667f3e533ebdbcd79c2d2ac873815ba5dbb9e040 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 9 Feb 2026 13:57:43 +0800 Subject: [PATCH 5/6] fix(agent): improve time awareness with timestamped turns --- src/agent/async-agent.test.ts | 35 +++++++- src/agent/async-agent.ts | 14 +++- src/agent/message-timestamp.test.ts | 57 +++++++++++++ src/agent/message-timestamp.ts | 100 +++++++++++++++++++++++ src/agent/system-prompt/builder.ts | 2 + src/agent/system-prompt/runtime-info.ts | 3 + src/agent/system-prompt/sections.test.ts | 22 ++++- src/agent/system-prompt/sections.ts | 24 ++++++ src/agent/system-prompt/types.ts | 2 + src/heartbeat/runner.test.ts | 17 +++- src/heartbeat/runner.ts | 2 +- 11 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 src/agent/message-timestamp.test.ts create mode 100644 src/agent/message-timestamp.ts diff --git a/src/agent/async-agent.test.ts b/src/agent/async-agent.test.ts index 3ebe01fd..f50cefc8 100644 --- a/src/agent/async-agent.test.ts +++ b/src/agent/async-agent.test.ts @@ -4,8 +4,8 @@ import { AsyncAgent } from "./async-agent.js"; const subscribeCallbacks: Array<(event: any) => void> = []; const internalRunState = { value: false }; -const runMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined })); -const runInternalMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined })); +const runMock = vi.fn(async (_prompt: string) => ({ text: "", thinking: undefined, error: undefined as string | undefined })); +const runInternalMock = vi.fn(async (_prompt: string) => ({ text: "", thinking: undefined, error: undefined as string | undefined })); const flushSessionMock = vi.fn(async () => {}); const persistAssistantSummaryMock = vi.fn(); const subscribeAllMock = vi.fn((fn: (event: any) => void) => { @@ -80,6 +80,8 @@ async function nextWithTimeout(iter: AsyncIterator, timeoutMs = 40): Promi } describe("AsyncAgent internal flow", () => { + const originalTz = process.env.TZ; + afterEach(() => { subscribeCallbacks.length = 0; internalRunState.value = false; @@ -91,6 +93,35 @@ describe("AsyncAgent internal flow", () => { runMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined }); runInternalMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined }); flushSessionMock.mockResolvedValue(undefined); + vi.useRealTimers(); + process.env.TZ = originalTz; + }); + + it("injects a timestamp prefix into external user writes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); + process.env.TZ = "America/New_York"; + const agent = new AsyncAgent(); + + agent.write("recent news"); + await agent.waitForIdle(); + + expect(runMock).toHaveBeenCalledTimes(1); + const [message] = runMock.mock.calls[0] ?? []; + expect(message).toMatch(/^\[Wed 2026-01-28 20:30 EST\] recent news$/); + + agent.close(); + }); + + it("allows disabling timestamp injection per write", async () => { + const agent = new AsyncAgent(); + + agent.write("raw heartbeat prompt", { injectTimestamp: false }); + await agent.waitForIdle(); + + expect(runMock).toHaveBeenCalledWith("raw heartbeat prompt"); + + agent.close(); }); it("filters internal events in direct subscribe stream", () => { diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index 9aed5913..824da617 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -4,6 +4,7 @@ import { Agent } from "./runner.js"; import { Channel } from "./channel.js"; import type { AgentOptions, Message } from "./types.js"; import type { MulticaEvent } from "./events.js"; +import { injectMessageTimestamp } from "./message-timestamp.js"; const devNull = { write: () => true } as unknown as NodeJS.WritableStream; @@ -17,6 +18,11 @@ export interface WriteInternalOptions { persistResponse?: boolean | undefined; } +export interface WriteOptions { + /** Disable automatic message timestamp injection */ + injectTimestamp?: boolean | undefined; +} + export class AsyncAgent { private readonly agent: Agent; private readonly channel = new Channel(); @@ -48,14 +54,18 @@ export class AsyncAgent { } /** Write message to agent (non-blocking, serialized queue) */ - write(content: string): void { + write(content: string, options?: WriteOptions): void { if (this._closed) throw new Error("Agent is closed"); this.pendingWrites += 1; + const message = + options?.injectTimestamp === false + ? content + : injectMessageTimestamp(content); this.queue = this.queue .then(async () => { if (this._closed) return; - const result = await this.agent.run(content); + const result = await this.agent.run(message); // Flush pending session writes so waitForIdle() callers // can safely read session data from disk. await this.agent.flushSession(); diff --git a/src/agent/message-timestamp.test.ts b/src/agent/message-timestamp.test.ts new file mode 100644 index 00000000..f49592bf --- /dev/null +++ b/src/agent/message-timestamp.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { injectMessageTimestamp, resolveMessageTimezone } from "./message-timestamp.js"; + +describe("injectMessageTimestamp", () => { + const originalTz = process.env.TZ; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); + process.env.TZ = "America/New_York"; + }); + + afterEach(() => { + vi.useRealTimers(); + process.env.TZ = originalTz; + }); + + it("prepends a compact timestamp prefix", () => { + const result = injectMessageTimestamp("Is it the weekend?"); + expect(result).toMatch(/^\[Wed 2026-01-28 20:30 EST\] Is it the weekend\?$/); + }); + + it("does not double-stamp already enveloped messages", () => { + const existing = "[Wed 2026-01-28 20:30 EST] hello"; + expect(injectMessageTimestamp(existing)).toBe(existing); + }); + + it("does not stamp cron messages that already include current time lines", () => { + const existing = "Cron run\nCurrent time: Wednesday, January 28th, 2026 — 8:30 PM (America/New_York)"; + expect(injectMessageTimestamp(existing)).toBe(existing); + }); + + it("returns empty/whitespace input unchanged", () => { + expect(injectMessageTimestamp("")).toBe(""); + expect(injectMessageTimestamp(" ")).toBe(" "); + }); +}); + +describe("resolveMessageTimezone", () => { + const originalTz = process.env.TZ; + + afterEach(() => { + process.env.TZ = originalTz; + }); + + it("prefers explicit argument when valid", () => { + process.env.TZ = "UTC"; + expect(resolveMessageTimezone("America/Chicago")).toBe("America/Chicago"); + }); + + it("falls back to UTC for invalid values", () => { + process.env.TZ = "Invalid/Timezone"; + const resolved = resolveMessageTimezone("also/invalid"); + expect(resolved).not.toBe("Invalid/Timezone"); + expect(resolved.length).toBeGreaterThan(0); + }); +}); diff --git a/src/agent/message-timestamp.ts b/src/agent/message-timestamp.ts new file mode 100644 index 00000000..6a9decfe --- /dev/null +++ b/src/agent/message-timestamp.ts @@ -0,0 +1,100 @@ +/** + * Message timestamp injection for time awareness. + * + * Keeps system prompt stable while giving the model a reliable "now" + * reference in each incoming user turn. + */ + +const CRON_TIME_PATTERN = /Current time:\s/; +const TIMESTAMP_ENVELOPE_PATTERN = /^\[.*\d{4}-\d{2}-\d{2} \d{2}:\d{2}/; + +export interface MessageTimestampOptions { + timeZone?: string; + now?: Date; +} + +export function resolveMessageTimezone(configured?: string): string { + const fromArg = configured?.trim(); + if (fromArg && isValidTimezone(fromArg)) { + return fromArg; + } + + const fromEnv = process.env.TZ?.trim(); + if (fromEnv && isValidTimezone(fromEnv)) { + return fromEnv; + } + + const hostTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return hostTimezone?.trim() || "UTC"; +} + +function isValidTimezone(timeZone: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date()); + return true; + } catch { + return false; + } +} + +function formatZonedTimestamp(date: Date, timeZone: string): string | undefined { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }).formatToParts(date); + + const pick = (type: string) => parts.find((part) => part.type === type)?.value; + const yyyy = pick("year"); + const mm = pick("month"); + const dd = pick("day"); + const hh = pick("hour"); + const min = pick("minute"); + const tz = [...parts] + .reverse() + .find((part) => part.type === "timeZoneName") + ?.value?.trim(); + + if (!yyyy || !mm || !dd || !hh || !min) { + return undefined; + } + + return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`; +} + +export function injectMessageTimestamp( + message: string, + opts?: MessageTimestampOptions, +): string { + if (!message.trim()) { + return message; + } + + if (TIMESTAMP_ENVELOPE_PATTERN.test(message)) { + return message; + } + + if (CRON_TIME_PATTERN.test(message)) { + return message; + } + + const now = opts?.now ?? new Date(); + const timeZone = resolveMessageTimezone(opts?.timeZone); + const formatted = formatZonedTimestamp(now, timeZone); + + if (!formatted) { + return message; + } + + const dow = new Intl.DateTimeFormat("en-US", { + timeZone, + weekday: "short", + }).format(now); + + return `[${dow} ${formatted}] ${message}`; +} diff --git a/src/agent/system-prompt/builder.ts b/src/agent/system-prompt/builder.ts index a8578616..78829ebf 100644 --- a/src/agent/system-prompt/builder.ts +++ b/src/agent/system-prompt/builder.ts @@ -20,6 +20,7 @@ import { buildSafetySection, buildSkillsSection, buildSubagentSection, + buildTimeAwarenessSection, buildToolCallStyleSection, buildToolingSummary, buildUserSection, @@ -66,6 +67,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): { { name: "conditional-tools", lines: buildConditionalToolSections(tools, mode) }, { name: "skills", lines: buildSkillsSection(skillsPrompt, mode) }, { name: "runtime", lines: buildRuntimeSection(runtime, mode) }, + { name: "time-awareness", lines: buildTimeAwarenessSection(tools, mode) }, { name: "profile-dir", lines: buildProfileDirSection(profileDir, mode) }, { name: "subagent", lines: buildSubagentSection(subagent, mode) }, { name: "extra", lines: buildExtraPromptSection(extraSystemPrompt, mode) }, diff --git a/src/agent/system-prompt/runtime-info.ts b/src/agent/system-prompt/runtime-info.ts index 0d1abf64..5a6bc09a 100644 --- a/src/agent/system-prompt/runtime-info.ts +++ b/src/agent/system-prompt/runtime-info.ts @@ -4,6 +4,7 @@ import os from "node:os"; import type { RuntimeInfo } from "./types.js"; +import { resolveMessageTimezone } from "../message-timestamp.js"; /** * Collect runtime environment information. @@ -16,6 +17,7 @@ export function collectRuntimeInfo(overrides?: Partial): RuntimeInf os: overrides?.os ?? process.platform, arch: overrides?.arch ?? process.arch, nodeVersion: overrides?.nodeVersion ?? process.version, + timezone: overrides?.timezone ?? resolveMessageTimezone(), provider: overrides?.provider, model: overrides?.model, cwd: overrides?.cwd ?? process.cwd(), @@ -38,6 +40,7 @@ export function formatRuntimeLine(info: RuntimeInfo): string { parts.push(`arch=${info.arch}`); } if (info.nodeVersion) parts.push(`node=${info.nodeVersion}`); + if (info.timezone) parts.push(`tz=${info.timezone}`); if (info.model) { const modelStr = info.provider ? `${info.provider}/${info.model}` : info.model; parts.push(`model=${modelStr}`); diff --git a/src/agent/system-prompt/sections.test.ts b/src/agent/system-prompt/sections.test.ts index 7d09b0ce..1830f9fc 100644 --- a/src/agent/system-prompt/sections.test.ts +++ b/src/agent/system-prompt/sections.test.ts @@ -8,6 +8,7 @@ import { buildSafetySection, buildSkillsSection, buildSubagentSection, + buildTimeAwarenessSection, buildToolCallStyleSection, buildToolingSummary, buildUserSection, @@ -209,7 +210,7 @@ describe("buildSkillsSection", () => { describe("buildRuntimeSection", () => { it("formats runtime info in full mode", () => { const result = buildRuntimeSection( - { agentName: "test", os: "darwin", arch: "arm64", nodeVersion: "v22.0.0", model: "claude", provider: "anthropic" }, + { agentName: "test", os: "darwin", arch: "arm64", nodeVersion: "v22.0.0", timezone: "UTC", model: "claude", provider: "anthropic" }, "full", ); const text = result.join("\n"); @@ -228,6 +229,25 @@ describe("buildRuntimeSection", () => { }); }); +describe("buildTimeAwarenessSection", () => { + it("includes time awareness in full mode", () => { + const result = buildTimeAwarenessSection(["exec"], "full"); + const text = result.join("\n"); + expect(text).toContain("## Time Awareness"); + expect(text).toContain("latest prefixed timestamp"); + expect(text).toContain("`exec`"); + }); + + it("includes time awareness in minimal mode", () => { + const result = buildTimeAwarenessSection(undefined, "minimal"); + expect(result.join("\n")).toContain("## Time Awareness"); + }); + + it("omits time awareness in none mode", () => { + expect(buildTimeAwarenessSection(["exec"], "none")).toEqual([]); + }); +}); + describe("buildProfileDirSection", () => { it("returns empty in all modes (merged into workspace section)", () => { // Profile directory info is now part of buildWorkspaceSection diff --git a/src/agent/system-prompt/sections.ts b/src/agent/system-prompt/sections.ts index 51b80f40..f0db6e33 100644 --- a/src/agent/system-prompt/sections.ts +++ b/src/agent/system-prompt/sections.ts @@ -314,6 +314,30 @@ export function buildRuntimeSection( return ["## Runtime", formatRuntimeLine(runtime)]; } +/** + * Time awareness section — helps the agent reason about "now" safely. + * Included in full and minimal modes. + */ +export function buildTimeAwarenessSection( + tools: string[] | undefined, + mode: SystemPromptMode, +): string[] { + if (mode === "none") return []; + + const hasExecTool = (tools ?? []).some((tool) => tool.toLowerCase() === "exec"); + const fallbackLine = hasExecTool + ? "If a turn lacks a timestamp and exact current time matters, use `exec` with `date`." + : "If a turn lacks a timestamp and exact current time matters, ask for clarification."; + + return [ + "## Time Awareness", + "Incoming user messages may include a prefix like `[Wed 2026-02-09 21:15 PST]`.", + "Treat the latest prefixed timestamp as your reference for relative time requests (today, recent, last month).", + fallbackLine, + "", + ]; +} + /** * Profile directory section — now merged into buildWorkspaceSection. * Kept for backwards compatibility but returns empty. diff --git a/src/agent/system-prompt/types.ts b/src/agent/system-prompt/types.ts index 2dc2b18e..9f75788e 100644 --- a/src/agent/system-prompt/types.ts +++ b/src/agent/system-prompt/types.ts @@ -31,6 +31,8 @@ export interface RuntimeInfo { arch?: string | undefined; /** Node.js version (e.g. "v22.0.0") */ nodeVersion?: string | undefined; + /** User-facing timezone for temporal reasoning (e.g. "America/Los_Angeles") */ + timezone?: string | undefined; /** Current working directory */ cwd?: string | undefined; } diff --git a/src/heartbeat/runner.test.ts b/src/heartbeat/runner.test.ts index 4a77c8fd..8a3570a3 100644 --- a/src/heartbeat/runner.test.ts +++ b/src/heartbeat/runner.test.ts @@ -9,7 +9,7 @@ type StubAgent = { sessionId: string; ensureInitialized: () => Promise; getMessages: () => Array; - write: (content: string) => void; + write: (content: string, options?: { injectTimestamp?: boolean }) => void; waitForIdle: () => Promise; getHeartbeatConfig: () => { prompt?: string; ackMaxChars?: number; enabled?: boolean }; getPendingWrites: () => number; @@ -71,4 +71,19 @@ describe("heartbeat runner", () => { expect(result.status).toBe("ran"); }); + + it("disables timestamp injection for heartbeat prompt writes", async () => { + const writes: Array<{ content: string; options?: { injectTimestamp?: boolean } }> = []; + const agent = createStubAgent({ replyText: "HEARTBEAT_OK" }); + const originalWrite = agent.write; + agent.write = (content, options) => { + writes.push(options ? { content, options } : { content }); + originalWrite(content, options); + }; + + await runHeartbeatOnce({ agent: agent as any, reason: "manual" }); + + expect(writes.length).toBeGreaterThan(0); + expect(writes[0]?.options?.injectTimestamp).toBe(false); + }); }); diff --git a/src/heartbeat/runner.ts b/src/heartbeat/runner.ts index 281adf9b..f9ba6bfa 100644 --- a/src/heartbeat/runner.ts +++ b/src/heartbeat/runner.ts @@ -176,7 +176,7 @@ export async function runHeartbeatOnce(opts: { ? `${basePrompt}\n\nSystem events:\n${pendingEvents.map((line) => `- ${line}`).join("\n")}` : basePrompt; - agent.write(prompt); + agent.write(prompt, { injectTimestamp: false }); await agent.waitForIdle(); const afterMessages = agent.getMessages(); From 59ae49e73f6d920cb04039f76f4cfcaef220db0f Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 9 Feb 2026 14:02:46 +0800 Subject: [PATCH 6/6] docs(readme): add time awareness flow section --- README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/README.md b/README.md index b72a0a72..429b4acb 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,43 @@ Web/Mobile Clients - **Gateway**: WebSocket server for remote clients - **Hub**: Agent lifecycle and event distribution +## Time + +Super Multica now uses **message-level timestamp injection** for time awareness. +Instead of placing dynamic time text in the system prompt, user turns are stamped at runtime. + +```mermaid +flowchart TD + A[Incoming turn] --> B{Entry point} + B -->|Desktop/Gateway/Cron/Subagent| C[AsyncAgent.write] + B -->|Heartbeat poll| D[AsyncAgent.write injectTimestamp=false] + C --> E{Already stamped or has 'Current time:'?} + E -->|Yes| F[Keep original message] + E -->|No| G[Prefix: [DOW YYYY-MM-DD HH:mm TZ]] + D --> H[Keep original heartbeat prompt] + F --> I[Agent.run] + G --> I + H --> I + I --> J[LLM receives final turn text] +``` + +### Injection Matrix + +| Path | Runtime call | Timestamp injected? | Notes | +| --- | --- | --- | --- | +| Desktop direct chat | `agent.write(content)` | Yes | Default behavior | +| Gateway/remote chat | `agent.write(content)` | Yes | Same entry path as desktop | +| `sessions_spawn` child task | `childAgent.write(task)` | Yes | Child turn gets current time context | +| Cron `agent-turn` payload | `agent.write(cronMessage)` | Yes (guarded) | Skips if message already carries `Current time:` | +| Heartbeat runner | `agent.write(prompt, { injectTimestamp: false })` | No | Prevents heartbeat prompt matching from breaking | +| Internal orchestration | `writeInternal(...)` | No | Uses separate internal run path | + +### Why this design + +- Keeps system prompt cache-stable (no per-turn date churn in system prompt text) +- Gives the model an explicit "now" reference on each user turn +- Uses guardrails to avoid double-stamping and heartbeat regressions + ## Scripts ```bash