Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
58398b5
feat(agent-core): add cron ClockSources abstraction
sailist May 27, 2026
eb2329e
feat(agent-core): add 5-field cron expression parser
sailist May 27, 2026
e3e1039
feat(agent-core): add deterministic cron jitter
sailist May 27, 2026
cb94ef8
feat(agent-core): add CronTask type and cron prompt origins
sailist May 27, 2026
98e50bb
test(agent-core): guard against Date.now() in cron scheduler files
sailist May 27, 2026
7313c0c
feat(agent-core): add cron telemetry event-name constants
sailist May 27, 2026
4d16970
feat(agent-core): add in-memory SessionCronStore
sailist May 27, 2026
6a1dd7e
feat(agent-core): add session-only CronScheduler engine
sailist May 27, 2026
dffbaf6
feat(agent-core): add CronManager Agent integration layer
sailist May 27, 2026
8678167
feat(agent-core): add CronCreate tool (session-only path)
sailist May 27, 2026
b11eac4
feat(agent-core): add CronList tool
sailist May 27, 2026
2fa086d
feat(agent-core): add CronDelete tool
sailist May 27, 2026
a4b4b38
feat(agent-core): wire CronManager + cron tools into Agent
sailist May 27, 2026
c1c9812
feat(agent-core): add manual-tick env + SIGUSR1 bench hook
sailist May 27, 2026
3f9eeae
test(agent-core): add end-to-end session cron smoke
sailist May 27, 2026
59d64fa
test(agent-core): extract shared cron test harness
sailist May 27, 2026
d9ac5a7
test(agent-core): trim cron test file headers
sailist May 27, 2026
6d09cfc
test(agent-core): convert cron tool output assertions to inline snaps…
sailist May 27, 2026
85885b8
refactor(cron): remove durable flag, env clock source, and enable cro…
sailist May 28, 2026
79c0c2e
fix(agent-core): address PR #136 typecheck and Codex review for cron …
sailist May 28, 2026
26414a6
fix(agent-core,tui): address deep review + Codex review on PR #136
sailist May 28, 2026
ba655b2
feat(cron): persist scheduled tasks across kimi resume
sailist May 28, 2026
84d1061
fix(agent-core): fix cron-stop-on-close test import for tsgo compat
sailist May 28, 2026
9944df3
feat(cron): persist lastFiredAt cursor and scope approval rules
sailist May 28, 2026
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
6 changes: 6 additions & 0 deletions .changeset/cron-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code": minor
---

Add session-scoped scheduled prompts so the assistant can register, list, and cancel recurring or one-shot reminders that fire later in the same session.
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ export class SessionReplayRenderer {
if (message.origin?.kind === 'injection') {
return;
}
// WHY: cron fires are not user turns (see isReplayUserTurnRecord); skip
// visual render and turn advance so the raw <cron-fire ...> envelope never
// surfaces in the resumed transcript.
if (message.origin?.kind === 'cron_job' || message.origin?.kind === 'cron_missed') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Advance or suppress the whole cron replay turn

When a resumed transcript contains a cron fire that produced assistant/tool output, this branch skips the cron user message without advancing the replay turn or suppressing the rest of that turn. The following assistant/tool records are still rendered, but with the previous (or undefined) currentTurnId, so scheduled-job output can be attached to the prior user prompt or replayed as an orphan. Either render a cron marker/advance the turn here, or skip the entire cron turn consistently.

Useful? React with 👍 / 👎.

return;
}

this.flushAssistant(context);
const skill = skillActivationFromOrigin(message.origin);
Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/utils/export-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ const INTERNAL_ORIGINS = new Set<PromptOrigin['kind']>([
'system_trigger',
'compaction_summary',
'hook_result',
// Cron fires are stored as user-role records carrying a `<cron-fire ...>`
// XML envelope meant only for the model. Replay and the TUI projector
// already hide them; the markdown exporter must do the same or the raw
// protocol XML leaks into the user-facing export.
'cron_job',
'cron_missed',
Comment on lines +104 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep cron responses out of the previous export turn

When a fired cron job has assistant/tool output in history, filtering only the cron-origin user message leaves the following assistant/tool records in the current export turn. For example, [user A, assistant A, cron_job user, assistant cron] exports assistant cron under Turn 1 with no scheduled-job marker, which misattributes autonomous scheduled work to the prior user prompt. Either render a sanitized cron turn boundary or suppress the whole cron-triggered turn consistently.

Useful? React with 👍 / 👎.

]);

export function isInternalMessage(msg: ContextMessage): boolean {
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/src/tui/utils/message-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ function isReplayUserTurnRecord(record: AgentReplayRecord): boolean {
return message.origin.trigger === 'user-slash';
case 'background_task':
case 'compaction_summary':
case 'cron_job':
case 'cron_missed':
case 'hook_result':
case 'injection':
case 'system_trigger':
Expand Down
21 changes: 21 additions & 0 deletions apps/kimi-code/test/tui/export-markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,27 @@ describe('isInternalMessage', () => {
).toBe(true);
});

it('marks cron_job origin as internal', () => {
expect(
isInternalMessage(
userMsg('x', {
kind: 'cron_job',
jobId: 'a1b2c3d4',
cron: '0 9 * * *',
recurring: true,
coalescedCount: 1,
stale: false,
}),
),
).toBe(true);
});

it('marks cron_missed origin as internal', () => {
expect(
isInternalMessage(userMsg('x', { kind: 'cron_missed', count: 2 })),
).toBe(true);
});

it('keeps real user messages', () => {
expect(isInternalMessage(userMsg('hello', { kind: 'user' }))).toBe(false);
});
Expand Down
48 changes: 48 additions & 0 deletions apps/kimi-code/test/tui/message-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,54 @@ describe('KimiTUI resume message replay', () => {
]);
});

it('skips cron_job origin records during replay', async () => {
const cronFire =
'<cron-fire jobId="job-1" cron="*/5 * * * *" recurring="true" coalescedCount="1">\nrun nightly\n</cron-fire>';
const driver = await replayIntoDriver([
message('user', [{ type: 'text', text: 'real prompt' }]),
message('assistant', [{ type: 'text', text: 'real answer' }]),
message('user', [{ type: 'text', text: cronFire }], {
origin: {
kind: 'cron_job',
jobId: 'job-1',
cron: '*/5 * * * *',
recurring: true,
coalescedCount: 1,
stale: false,
},
}),
]);

const transcript = driver.state.transcriptContainer.render(120).join('\n');
expect(transcript).not.toContain('<cron-fire');
expect(
driver.state.transcriptEntries
.filter((entry) => entry.kind === 'user')
.map((entry) => entry.content),
).toEqual(['real prompt']);
});

it('skips cron_missed origin records during replay', async () => {
const cronMissed =
'<cron-fire jobId="job-2" missed="true" count="3">\n3 one-shot tasks missed while offline\n</cron-fire>';
const driver = await replayIntoDriver([
message('user', [{ type: 'text', text: 'real prompt' }]),
message('assistant', [{ type: 'text', text: 'real answer' }]),
message('user', [{ type: 'text', text: cronMissed }], {
origin: { kind: 'cron_missed', count: 3 },
}),
]);

const transcript = driver.state.transcriptContainer.render(120).join('\n');
expect(transcript).not.toContain('<cron-fire');
expect(transcript).not.toContain('missed while offline');
expect(
driver.state.transcriptEntries
.filter((entry) => entry.kind === 'user')
.map((entry) => entry.content),
).toEqual(['real prompt']);
});

it('renders user-slash skill activation once without exposing injected prompt text', async () => {
const activation = message(
'user',
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/agent/context/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ function isInjectionUserMessage(message: Message): boolean {
if (trimmed.startsWith('<notification ')) return true;
if (trimmed.startsWith('<system-reminder>')) return true;
if (trimmed.startsWith('<hook_result ')) return true;
if (trimmed.startsWith('<cron-fire ')) return true;
return false;
}

Expand Down
19 changes: 19 additions & 0 deletions packages/agent-core/src/agent/context/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@ export interface BackgroundTaskOrigin {
readonly notificationId: string;
}

export interface CronJobOrigin {
readonly kind: 'cron_job';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hide cron fire records from markdown export

Adding this internal origin means cron fire messages now enter ContextMessage history, but the markdown exporter only filters injection, system_trigger, compaction_summary, and hook_result in apps/kimi-code/src/tui/utils/export-markdown.ts. After any cron job fires, exporting the session will therefore create a user turn containing the raw <cron-fire ...> envelope that replay explicitly hides, leaking implementation XML into a user-facing export; add the cron origins to the exporter’s internal-message filter or render them in a sanitized form.

Useful? React with 👍 / 👎.

readonly jobId: string;
readonly cron: string;
readonly recurring: boolean;
/** Number of theoretical fires that were collapsed into this single delivery (>= 1). */
readonly coalescedCount: number;
/** True for recurring tasks past the 7-day age threshold. */
readonly stale: boolean;
}

export interface CronMissedOrigin {
readonly kind: 'cron_missed';
/** Number of one-shot tasks bundled into this missed-fire notification. */
readonly count: number;
}

export interface HookResultOrigin {
readonly kind: 'hook_result';
readonly event: string;
Expand All @@ -55,6 +72,8 @@ export type PromptOrigin =
| CompactionSummaryOrigin
| SystemTriggerOrigin
| BackgroundTaskOrigin
| CronJobOrigin
| CronMissedOrigin
Comment on lines +75 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hide cron-origin messages from markdown export

Adding cron_job/cron_missed as user-role origins also requires treating them as internal in the TUI markdown export path; apps/kimi-code/src/tui/utils/export-markdown.ts currently only filters injection/system/compaction/hook origins. After any scheduled job fires, exporting the session will include the raw <cron-fire ...> envelope as a user turn and may make it the overview topic, even though replay explicitly hides these same records.

Useful? React with 👍 / 👎.

| HookResultOrigin;

export type ContextMessage = Message & {
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/agent/cron/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CronManager, type CronManagerOptions } from './manager';
Loading
Loading