Merge pull request #108 from multica-ai/Bohan-J/add-time-awareness
fix(agent): improve time awareness with timestamped turns
This commit is contained in:
commit
5c26f92caa
12 changed files with 308 additions and 7 deletions
37
README.md
37
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
|
||||
|
|
|
|||
|
|
@ -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<T>(iter: AsyncIterator<T>, 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", () => {
|
||||
|
|
|
|||
|
|
@ -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<ChannelItem>();
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
57
src/agent/message-timestamp.test.ts
Normal file
57
src/agent/message-timestamp.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
100
src/agent/message-timestamp.ts
Normal file
100
src/agent/message-timestamp.ts
Normal file
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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<RuntimeInfo>): 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}`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ type StubAgent = {
|
|||
sessionId: string;
|
||||
ensureInitialized: () => Promise<void>;
|
||||
getMessages: () => Array<any>;
|
||||
write: (content: string) => void;
|
||||
write: (content: string, options?: { injectTimestamp?: boolean }) => void;
|
||||
waitForIdle: () => Promise<void>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue