Merge remote-tracking branch 'origin/main' into feat/telegram-channel
# Conflicts: # apps/desktop/src/hooks/use-local-chat.ts # packages/sdk/src/actions/stream.ts # packages/ui/src/components/chat-view.tsx # src/agent/async-agent.ts # src/agent/events.ts
This commit is contained in:
commit
0895d42d3b
21 changed files with 462 additions and 60 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
|
||||
|
|
|
|||
|
|
@ -282,6 +282,7 @@ export function registerHubIpcHandlers(): void {
|
|||
return
|
||||
}
|
||||
|
||||
|
||||
// Filter events same as Hub.consumeAgent()
|
||||
const maybeMessage = (event as { message?: { role?: string } }).message
|
||||
const isAssistantMessage = maybeMessage?.role === 'assistant'
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-destructive">
|
||||
|
|
@ -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 (
|
||||
<ChatView
|
||||
messages={messages}
|
||||
streamingIds={streamingIds}
|
||||
isLoading={isLoading}
|
||||
isLoadingHistory={isLoadingHistory}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
error={error}
|
||||
pendingApprovals={pendingApprovals}
|
||||
sendMessage={sendMessage}
|
||||
loadMore={loadMore}
|
||||
resolveApproval={resolveApproval}
|
||||
/>
|
||||
<>
|
||||
<ChatView
|
||||
messages={messages}
|
||||
streamingIds={streamingIds}
|
||||
isLoading={isLoading}
|
||||
isLoadingHistory={isLoadingHistory}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
error={error}
|
||||
pendingApprovals={pendingApprovals}
|
||||
sendMessage={sendMessage}
|
||||
loadMore={loadMore}
|
||||
resolveApproval={resolveApproval}
|
||||
errorAction={errorAction}
|
||||
/>
|
||||
|
||||
{currentMeta && currentMeta.authMethod === 'api-key' && (
|
||||
<ApiKeyDialog
|
||||
open={apiKeyDialogOpen}
|
||||
onOpenChange={setApiKeyDialogOpen}
|
||||
providerId={currentMeta.id}
|
||||
providerName={currentMeta.name}
|
||||
onSuccess={handleProviderConfigSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentMeta && currentMeta.authMethod === 'oauth' && (
|
||||
<OAuthDialog
|
||||
open={oauthDialogOpen}
|
||||
onOpenChange={setOauthDialogOpen}
|
||||
providerId={currentMeta.id}
|
||||
providerName={currentMeta.name}
|
||||
loginCommand={currentMeta.loginCommand}
|
||||
onSuccess={handleProviderConfigSuccess}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
ExecApprovalRequestPayload,
|
||||
ApprovalDecision,
|
||||
AgentMessageItem,
|
||||
AgentErrorEvent,
|
||||
} from '@multica/sdk'
|
||||
import { DEFAULT_MESSAGES_LIMIT } from '@multica/sdk'
|
||||
|
||||
|
|
@ -56,10 +57,10 @@ export function useLocalChat() {
|
|||
const payload = data as unknown as StreamPayload
|
||||
if (!payload.event) return
|
||||
|
||||
// Handle agent errors as transient UI feedback (not persisted to history)
|
||||
// Handle agent error events
|
||||
if (payload.event.type === 'agent_error') {
|
||||
const errorMsg = (payload.event as { error?: string }).error ?? 'Unknown error'
|
||||
chatRef.current.setError({ code: 'AGENT_ERROR', message: errorMsg })
|
||||
const errorEvent = payload.event as AgentErrorEvent
|
||||
chatRef.current.setError({ code: 'AGENT_ERROR', message: errorEvent.message })
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
|
@ -141,6 +142,10 @@ export function useLocalChat() {
|
|||
[],
|
||||
)
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
chatRef.current.setError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
agentId,
|
||||
initError,
|
||||
|
|
@ -155,5 +160,6 @@ export function useLocalChat() {
|
|||
sendMessage,
|
||||
loadMore,
|
||||
resolveApproval,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions
|
|||
if (msg.action === StreamAction) {
|
||||
const payload = msg.payload as StreamPayload;
|
||||
if (payload.event.type === "agent_error") {
|
||||
const errorMsg = (payload.event as { error?: string }).error ?? "Unknown error";
|
||||
const errorMsg = (payload.event as { message?: string }).message ?? "Unknown error";
|
||||
chat.setError({ code: "AGENT_ERROR", message: errorMsg });
|
||||
setIsLoading(false);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -45,17 +45,17 @@ export type CompactionEndEvent = {
|
|||
/** Union of all compaction events */
|
||||
export type CompactionEvent = CompactionStartEvent | CompactionEndEvent;
|
||||
|
||||
/** Emitted when the agent encounters an error (LLM failure, quota exceeded, etc.) */
|
||||
/** Emitted when an agent encounters an error during execution */
|
||||
export type AgentErrorEvent = {
|
||||
type: "agent_error";
|
||||
error: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
// --- Stream event types ---
|
||||
|
||||
/**
|
||||
* Hub forwards AgentEvent from pi-agent-core, CompactionEvent,
|
||||
* and AgentErrorEvent as-is. StreamPayload wraps them with routing metadata.
|
||||
* Hub forwards AgentEvent from pi-agent-core, CompactionEvent, and AgentErrorEvent as-is.
|
||||
* StreamPayload wraps them with routing metadata.
|
||||
*/
|
||||
export interface StreamPayload {
|
||||
streamId: string;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,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({
|
||||
|
|
@ -54,6 +56,7 @@ export function ChatView({
|
|||
loadMore,
|
||||
resolveApproval,
|
||||
onDisconnect,
|
||||
errorAction,
|
||||
}: ChatViewProps) {
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -224,16 +227,28 @@ export function ChatView({
|
|||
{error.message}
|
||||
</MemoizedMarkdown>
|
||||
</span>
|
||||
{onDisconnect && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDisconnect}
|
||||
className="shrink-0 text-xs h-7 px-2.5"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{errorAction && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={errorAction.onClick}
|
||||
className="shrink-0 text-xs h-7 px-2.5"
|
||||
>
|
||||
{errorAction.label}
|
||||
</Button>
|
||||
)}
|
||||
{onDisconnect && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDisconnect}
|
||||
className="shrink-0 text-xs h-7 px-2.5"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -241,8 +256,8 @@ export function ChatView({
|
|||
<footer className="container px-4 pb-3 pt-1">
|
||||
<ChatInput
|
||||
onSubmit={sendMessage}
|
||||
disabled={isLoading || !!error}
|
||||
placeholder={error ? "Connection error" : "Ask your Agent..."}
|
||||
disabled={isLoading || (!!error && error.code !== 'AGENT_ERROR')}
|
||||
placeholder={error && error.code !== 'AGENT_ERROR' ? "Connection error" : "Ask your Agent..."}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,19 +54,18 @@ export class AsyncAgent {
|
|||
}
|
||||
|
||||
/** Write message to agent (non-blocking, serialized queue) */
|
||||
write(content: string): void {
|
||||
this.enqueue(() => this.agent.run(content));
|
||||
}
|
||||
|
||||
/** Enqueue an agent run, handling errors and session flush */
|
||||
private enqueue(runFn: () => ReturnType<Agent["run"]>): 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 runFn();
|
||||
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();
|
||||
|
|
@ -68,14 +73,15 @@ export class AsyncAgent {
|
|||
if (result.error) {
|
||||
console.error(`[AsyncAgent] Agent run error: ${result.error}`);
|
||||
this.channel.send({ id: uuidv7(), content: `[error] ${result.error}` });
|
||||
this.agent.emitMulticaEvent({ type: "agent_error", error: result.error });
|
||||
this.agent.emitError(result.error);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[AsyncAgent] Agent run exception: ${message}`);
|
||||
this.channel.send({ id: uuidv7(), content: `[error] ${message}` });
|
||||
this.agent.emitMulticaEvent({ type: "agent_error", 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);
|
||||
|
|
|
|||
|
|
@ -21,15 +21,15 @@ 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 the agent encounters an error (LLM failure, quota exceeded, etc.) */
|
||||
/** Emitted when an agent encounters an error during execution */
|
||||
export type AgentErrorEvent = {
|
||||
type: "agent_error";
|
||||
error: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/** Union of all Multica-specific events */
|
||||
|
|
|
|||
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}`;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -163,11 +163,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
|
||||
|
|
@ -356,6 +359,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<AgentRunResult> {
|
||||
// Run-level mutex: prevents concurrent run/runInternal from mis-tagging messages
|
||||
return this.withRunMutex(() => this._run(prompt));
|
||||
|
|
@ -402,6 +410,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;
|
||||
|
||||
|
|
@ -509,14 +525,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ export class ChannelManager {
|
|||
if (event.type === "agent_error") {
|
||||
this.stopTyping();
|
||||
this.removeAckReaction();
|
||||
const errorMsg = (event as { error?: string }).error ?? "Unknown error";
|
||||
const errorMsg = (event as { message?: string }).message ?? "Unknown error";
|
||||
console.error(`[Channels] Agent error: ${errorMsg}`);
|
||||
const route = this.lastRoute;
|
||||
if (route) {
|
||||
|
|
|
|||
|
|
@ -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