Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/persist-step-retries-and-tool-progress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/agent-core": patch
---

Record step retries and a small tool-progress summary in the agent wire (additive optional fields) so debug tooling can surface them.
8 changes: 6 additions & 2 deletions apps/vis/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@
"@hono/node-server": "^1.13.7",
"@moonshot-ai/agent-core": "workspace:^",
"@moonshot-ai/kosong": "workspace:^",
"hono": "^4.7.7"
"hono": "^4.7.7",
"yauzl": "^3.3.0"
},
"devDependencies": {
"@types/yauzl": "^2.10.3",
"@types/yazl": "^2.4.6",
"tsx": "^4.21.0",
"vitest": "4.1.4"
"vitest": "4.1.4",
"yazl": "^3.3.1"
}
}
8 changes: 8 additions & 0 deletions apps/vis/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import { KIMI_CODE_HOME } from './config';
import { serveWebAsset, type WebAsset } from './lib/web-asset';
import { blobsRoute } from './routes/blobs';
import { contextRoute } from './routes/context';
import { cronRoute } from './routes/cron';
import { importsRoute } from './routes/imports';
import { logsRoute } from './routes/logs';
import { sessionDetailRoute } from './routes/session-detail';
import { sessionsRoute } from './routes/sessions';
import { subagentsRoute } from './routes/subagents';
import { tasksRoute } from './routes/tasks';
import { wireRoute } from './routes/wire';

/** Resolve the SPA bundle directory next to the compiled server.mjs, if it
Expand Down Expand Up @@ -96,6 +100,10 @@ export async function createApp(options: CreateAppOptions = {}): Promise<Hono> {
api.route('/sessions', wireRoute(home));
api.route('/sessions', subagentsRoute(home));
api.route('/sessions', blobsRoute(home));
api.route('/sessions', tasksRoute(home));
api.route('/sessions', cronRoute(home));
api.route('/sessions', logsRoute(home));
api.route('/imports', importsRoute(home));
// Mount contextRoute last because it currently uses a catch-all stub
// (Phase C scope) that would otherwise shadow more specific routes
// registered below it.
Expand Down
159 changes: 155 additions & 4 deletions apps/vis/server/src/lib/agent-record-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,76 @@ export type {
LoopRecordedEvent,
ContextMessage,
PromptOrigin,
LoopStepRetryRecord,
LoopToolProgressSummary,
// Background-task shapes are part of agent-core's public surface, so the
// visualizer tracks them directly instead of duplicating the union.
BackgroundTaskInfo,
BackgroundTaskStatus,
ProcessBackgroundTaskInfo,
AgentBackgroundTaskInfo,
QuestionBackgroundTaskInfo,
} from '@moonshot-ai/agent-core';
export { AGENT_WIRE_PROTOCOL_VERSION } from '@moonshot-ai/agent-core';
export type { Message, ContentPart, ToolCall, TokenUsage } from '@moonshot-ai/kosong';

// Local binding for the `AgentRecord` type used by the vis-only DTOs below
// (e.g. `WireEntry.data`). The `export type { … }` re-export above forwards
// the name to consumers but does NOT bring it into this module's scope.
import type { AgentRecord } from '@moonshot-ai/agent-core';
// Local bindings for the upstream types referenced by the vis-only DTOs
// below. The `export type { … }` re-export above forwards the names to
// consumers but does NOT bring them into this module's scope.
import type { AgentRecord, BackgroundTaskInfo } from '@moonshot-ai/agent-core';

/**
* Persistent representation of a cron task.
*
* Structural mirror of agent-core's `CronTask` (`tools/cron/types.ts`),
* which is NOT re-exported from the package entry point. The shape is
* tiny and frozen; `cron-store.test.ts` reads a fixture written in the
* real on-disk format so the mirror cannot silently drift from disk.
*/
export interface CronTask {
readonly id: string;
readonly cron: string;
readonly prompt: string;
readonly createdAt: number;
readonly recurring?: boolean;
readonly lastFiredAt?: number;
}

/**
* `manifest.json` shape inside a `/export-debug-zip` bundle. Structural
* mirror of agent-core's `ExportSessionManifest` (`rpc/core-api.ts`), which
* is not re-exported from the package entry. All fields optional-tolerant
* because the manifest comes from another machine / kimi-code version.
*/
export interface ImportManifest {
sessionId?: string;
exportedAt?: string;
kimiCodeVersion?: string;
wireProtocolVersion?: string;
os?: string;
nodejsVersion?: string;
sessionFirstActivity?: string;
sessionLastActivity?: string;
title?: string;
workspaceDir?: string;
sessionLogPath?: string;
globalLogPath?: string;
installSource?: string;
shellEnv?: unknown;
}

/** vis-side bookkeeping for one imported bundle, written to
* `imported/<importId>/import-meta.json`. */
export interface ImportInfo {
/** vis-generated id (`imp_…`); also the session id the UI addresses. */
importId: string;
/** ISO time the zip was imported into vis. */
importedAt: string;
/** Original uploaded file name, when known. */
originalName: string | null;
/** Parsed `manifest.json`, when present and readable. */
manifest: ImportManifest | null;
}

// ── vis-only DTOs ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -58,6 +120,10 @@ export interface SessionSummary {
mainWireRecordCount: number;
wireProtocolVersion: string | null;
health: SessionHealth;
/** True for sessions imported from a debug zip (under `<home>/imported/`). */
imported: boolean;
/** Export/import provenance for imported sessions; null for local ones. */
importMeta: ImportInfo | null;
}

export interface AgentInfo {
Expand All @@ -84,6 +150,10 @@ export interface SessionDetail {
workDir: string;
state: unknown; // 原样透传,前端按 state.json 真实形状渲染
agents: AgentInfo[];
/** True for sessions imported from a debug zip. */
imported: boolean;
/** Export/import provenance for imported sessions; null for local ones. */
importMeta: ImportInfo | null;
}

/** One line of `wire.jsonl` after vis has parsed (and possibly migrated)
Expand Down Expand Up @@ -122,3 +192,84 @@ export interface AgentTreeResponse {
sessionId: string;
tree: AgentNode[];
}

// ── background tasks & cron ─────────────────────────────────────────────────

/** A persisted background task plus vis-derived `output.log` metadata.
* `task` is the normalized agent-core shape; the size/exists fields let the
* UI badge how much output a task produced and offer a "view log" affordance
* without first fetching the (potentially large) log body. */
export interface BackgroundTaskEntry {
task: BackgroundTaskInfo;
/** Which agent persisted this task — tasks live under the spawning agent's
* homedir (`<session>/agents/<agentId>/tasks`), not the session root. */
agentId: string;
/** Total byte size of the task's `output.log` (0 when absent). */
outputSizeBytes: number;
/** Whether an `output.log` file exists for this task. */
outputExists: boolean;
}

export interface BackgroundTasksResponse {
sessionId: string;
tasks: BackgroundTaskEntry[];
}

/** One byte-window of a task's `output.log`. Byte-level (not line-level)
* paging mirrors how the log is stored on disk, so arbitrarily large logs
* can be paged without loading the whole file. */
export interface TaskOutputResponse {
sessionId: string;
taskId: string;
/** Byte offset this window starts at. */
offset: number;
/** Byte offset immediately after this window; pass as the next `offset`
* to page forward without drift. */
nextOffset: number;
/** Total byte size of the log on disk. */
size: number;
/** UTF-8 decoded window content. */
content: string;
/** True when this window reaches the end of the log. */
eof: boolean;
}

export interface CronTasksResponse {
sessionId: string;
cron: CronTask[];
}

// ── imported sessions & logs ────────────────────────────────────────────────

/** Result of importing a debug zip. */
export interface ImportResult {
/** The `imp_…` id the UI uses to address the imported session. */
sessionId: string;
importMeta: ImportInfo;
}

/** One parsed line of a diagnostic log. */
export interface LogLine {
/** 1-indexed line number in the source log. */
lineNo: number;
/** ISO timestamp parsed from the line prefix, or null if unparseable. */
time: string | null;
/** Log level (INFO / WARN / ERROR / DEBUG / …), uppercased, or null. */
level: string | null;
/** The human message between the level and the structured fields. */
message: string;
/** Parsed trailing `key=value` fields. */
fields: Record<string, string>;
/** The original line, verbatim. */
raw: string;
}

export interface LogsResponse {
sessionId: string;
which: 'session' | 'global';
/** Which logs exist on disk for this session. */
available: { session: boolean; global: boolean };
lines: LogLine[];
/** True when the log was longer than the served cap and got truncated. */
truncated: boolean;
}
67 changes: 67 additions & 0 deletions apps/vis/server/src/lib/cron-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// apps/vis/server/src/lib/cron-store.ts
//
// Read-only reader for cron tasks, persisted by agent-core under each (non-sub)
// agent's homedir at `<agentDir>/cron/<id>.json` (callers pass the agent
// homedir, `<session>/agents/<id>`). The visualizer never writes these files;
// it mirrors agent-core's on-disk layout (tools/cron/persist.ts) for reading.

import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';

import type { CronTask } from './agent-record-types';

/** Cron id format: 8 lowercase hex chars (mirror of agent-core's cron-id
* shape). Enforced before joining a path so a stray / hand-edited filename
* cannot escape the cron directory. */
const VALID_CRON_ID = /^[0-9a-f]{8}$/;

export function isSafeCronId(id: string): boolean {
return VALID_CRON_ID.test(id);
}

function cronDirOf(agentDir: string): string {
return join(agentDir, 'cron');
}

/**
* Enumerate all persisted cron tasks for a session, sorted by creation time
* (oldest first, matching how a user scheduled them).
*
* Silently skips filenames that don't match `VALID_CRON_ID`, files that fail
* to read/parse, and records missing the required cron fields.
*/
export async function listCronTasks(agentDir: string): Promise<CronTask[]> {
const dir = cronDirOf(agentDir);
let entries: import('node:fs').Dirent[];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const out: CronTask[] = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const id = entry.name.slice(0, -'.json'.length);
if (!VALID_CRON_ID.test(id)) continue;
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(join(dir, entry.name), 'utf8'));
} catch {
continue;
}
if (isCronTask(parsed)) out.push(parsed);
}
out.sort((a, b) => a.createdAt - b.createdAt);
return out;
}

function isCronTask(value: unknown): value is CronTask {
if (typeof value !== 'object' || value === null) return false;
const o = value as Record<string, unknown>;
return (
typeof o['id'] === 'string' &&
typeof o['cron'] === 'string' &&
typeof o['prompt'] === 'string' &&
typeof o['createdAt'] === 'number'
);
}
Loading
Loading