d4faee3a1e
Branch-length batch covering tooling, the model layer, the Web App and the public surfaces. Highlights: - Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each change touches, with a root CHANGELOG.md holding one line per release. - Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a lock and keeps `pnpm install` current; `pnpm dev` runs server+web together. - AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity` object in place of item-level `signature`/`phase`, threaded verbatim through Trace, replay and resume; malformed classification adapted to the new error types. - Model layer: a model is always referenced by an explicit `(provider, model_id)` pair. The provider is never inferred, guessed or defaulted -- both the catalog inference and the unique-match config resolution are gone, and CLI, SDK, server routes and run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group. - Web App: catalog preset sync and per-group speed test on the Models page, positional slash commands, a markdown renderer, skill-library update reminders, and a vertically centred draft page whose upward menus size themselves to the room available. - Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed benchmark results for both suites, and the demo videos playing on the landing page. Includes the fixes from a full review of the branch: 23 confirmed findings, among them a provider-inference bug that could send one vendor's API key to another vendor's endpoint, and an Escape handler that destroyed the composer's contents unrecoverably. Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and pnpm format:check clean, Playwright e2e 14/14.
257 lines
9.3 KiB
TypeScript
257 lines
9.3 KiB
TypeScript
/**
|
||
* Agent service.
|
||
*
|
||
* The list is the union of "DB index ∪ directory scan": a subdirectory under
|
||
* `<project>/` containing `agent_state/system_config.yaml` is treated as an Agent;
|
||
* unmanaged ones found are backfilled into the DB — this handles Agents created
|
||
* directly via the CLI.
|
||
* Create: generate agent-<8hex>, initialize Agent State via core's `createAgent`,
|
||
* then write name/description into system_config.yaml (parseDocument preserves the
|
||
* template's comments).
|
||
*/
|
||
import fs from "node:fs/promises";
|
||
import type { Dirent } from "node:fs";
|
||
import path from "node:path";
|
||
import { HttpError } from "../http/errors.js";
|
||
import {
|
||
agentDir,
|
||
agentsDir,
|
||
agentsMdPath,
|
||
BUILTIN_AGENT_IDS,
|
||
createAgent as coreCreateAgent,
|
||
isValidId,
|
||
loadAgentVault,
|
||
scheduleDir,
|
||
skillsDir,
|
||
systemConfigPath,
|
||
} from "@prismshadow/penguin-core";
|
||
import type { AgentsRepo } from "../db/repos/agents.js";
|
||
import { SEMANTIC_ID_PATTERN, SEMANTIC_ID_RULE } from "./ids.js";
|
||
import type { AgentConfigService } from "./agent-config-service.js";
|
||
|
||
export interface AgentListItem {
|
||
agentId: string;
|
||
name?: string;
|
||
description?: string;
|
||
createdAt?: string;
|
||
/** Last config modification time: the later of system_config.yaml / AGENTS.md mtime. */
|
||
updatedAt?: string;
|
||
/** Tool count: number of tools.builtin + tools.mcpServers entries (MCP counted per server). */
|
||
toolCount: number;
|
||
/** Agent State version number (missing field treated as 1). */
|
||
version: number;
|
||
/** Number of vault keys. */
|
||
vaultKeyCount: number;
|
||
/** Number of scheduled tasks (count of .toml files under schedule/, including invalid ones). */
|
||
scheduleCount: number;
|
||
/** Number of installed Skills (count of skills/<name>/ directories that contain a SKILL.md). */
|
||
skillCount: number;
|
||
}
|
||
|
||
export class AgentService {
|
||
constructor(
|
||
private readonly root: string,
|
||
private readonly agents: AgentsRepo,
|
||
private readonly agentConfig: AgentConfigService,
|
||
) {}
|
||
|
||
/** Union of DB index ∪ directory scan; unmanaged directory Agents are backfilled into the DB. */
|
||
async listAgents(projectId: string): Promise<AgentListItem[]> {
|
||
const known = new Map(this.agents.list(projectId).map((r) => [r.agentId, r]));
|
||
|
||
let entries: string[] = [];
|
||
try {
|
||
const dirents = await fs.readdir(agentsDir(this.root, projectId), { withFileTypes: true });
|
||
entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
|
||
} catch {
|
||
// The Project's agents/ directory doesn't exist yet (no Agent directories): return from the DB index only.
|
||
}
|
||
for (const agentId of entries) {
|
||
if (known.has(agentId) || !isValidId(agentId)) continue;
|
||
const configPath = systemConfigPath(this.root, projectId, agentId);
|
||
let createdAt: string;
|
||
try {
|
||
const stat = await fs.stat(configPath);
|
||
createdAt = (stat.birthtime.getTime() > 0 ? stat.birthtime : stat.mtime).toISOString();
|
||
} catch {
|
||
continue; // A directory without system_config.yaml is not an Agent (e.g. a temp folder)
|
||
}
|
||
const row = { projectId, agentId, createdAt };
|
||
this.agents.insertOrIgnore(row);
|
||
known.set(agentId, row);
|
||
}
|
||
|
||
// Meta reads and mtime stats for each Agent run in parallel (Promise.all preserves the sorted order).
|
||
const sorted = [...known.values()].sort((a, b) =>
|
||
a.createdAt === b.createdAt
|
||
? a.agentId.localeCompare(b.agentId)
|
||
: a.createdAt < b.createdAt
|
||
? -1
|
||
: 1,
|
||
);
|
||
return Promise.all(
|
||
sorted.map(async (row) => {
|
||
const [meta, updatedAt, vaultKeyCount, scheduleCount, skillCount] = await Promise.all([
|
||
this.agentConfig.readCardMeta(projectId, row.agentId),
|
||
this.configUpdatedAt(projectId, row.agentId),
|
||
this.vaultKeyCount(projectId, row.agentId),
|
||
this.scheduleCount(projectId, row.agentId),
|
||
this.skillCount(projectId, row.agentId),
|
||
]);
|
||
return {
|
||
agentId: row.agentId,
|
||
...meta,
|
||
createdAt: row.createdAt,
|
||
...(updatedAt !== undefined ? { updatedAt } : {}),
|
||
vaultKeyCount,
|
||
scheduleCount,
|
||
skillCount,
|
||
};
|
||
}),
|
||
);
|
||
}
|
||
|
||
/** Number of vault keys (falls back to 0 on read failure). */
|
||
private async vaultKeyCount(projectId: string, agentId: string): Promise<number> {
|
||
try {
|
||
return Object.keys(await loadAgentVault(this.root, projectId, agentId)).length;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/** Number of scheduled tasks: count of .toml files under schedule/ (0 if the directory doesn't exist). */
|
||
private async scheduleCount(projectId: string, agentId: string): Promise<number> {
|
||
try {
|
||
const names = await fs.readdir(scheduleDir(this.root, projectId, agentId));
|
||
return names.filter((n) => n.endsWith(".toml")).length;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/** Number of installed Skills: count of skills/<name>/ directories containing a SKILL.md (0 if the directory doesn't exist). */
|
||
private async skillCount(projectId: string, agentId: string): Promise<number> {
|
||
const base = skillsDir(this.root, projectId, agentId);
|
||
let dirents: Dirent[];
|
||
try {
|
||
dirents = await fs.readdir(base, { withFileTypes: true });
|
||
} catch {
|
||
return 0;
|
||
}
|
||
const present = await Promise.all(
|
||
dirents
|
||
.filter((d) => d.isDirectory())
|
||
.map(async (d) => {
|
||
try {
|
||
await fs.access(path.join(base, d.name, "SKILL.md"));
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}),
|
||
);
|
||
return present.filter(Boolean).length;
|
||
}
|
||
|
||
/** Last config modification time: the later of system_config.yaml and AGENTS.md mtime; omitted if neither is readable. */
|
||
private async configUpdatedAt(projectId: string, agentId: string): Promise<string | undefined> {
|
||
const paths = [
|
||
systemConfigPath(this.root, projectId, agentId),
|
||
agentsMdPath(this.root, projectId, agentId),
|
||
];
|
||
const times = await Promise.all(
|
||
paths.map(async (p) => {
|
||
try {
|
||
return (await fs.stat(p)).mtime.getTime();
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}),
|
||
);
|
||
const max = Math.max(...times);
|
||
return max > 0 ? new Date(max).toISOString() : undefined;
|
||
}
|
||
|
||
/**
|
||
* Delete an Agent: the sole built-in Agent
|
||
* default_agent (shared with the CLI, the default conversation Agent) cannot be
|
||
* deleted; callers must first drain any active run via manager.abortAgent.
|
||
* The directory is deleted recursively (including Trace), and the DB's
|
||
* agents/sessions index rows are removed along with it; usage records are kept
|
||
* (historical stats are unaffected).
|
||
*/
|
||
async deleteAgent(projectId: string, agentId: string): Promise<void> {
|
||
if (BUILTIN_AGENT_IDS.includes(agentId)) {
|
||
throw new HttpError(
|
||
409,
|
||
"cannot_delete_builtin_agent",
|
||
"Built-in Agents (default_agent) are provisioned with the Project and cannot be deleted from the web.",
|
||
);
|
||
}
|
||
await fs.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
|
||
this.agents.delete(projectId, agentId);
|
||
}
|
||
|
||
/**
|
||
* Create an Agent: the id is chosen by the creator (a semantic id, checked for
|
||
* duplicates against both the DB and the directory within the Project — a 409
|
||
* if taken, which naturally also blocks built-in Agent ids) → initialize State →
|
||
* write name/description (name defaults to the id).
|
||
*/
|
||
async createAgent(
|
||
projectId: string,
|
||
agentId: string,
|
||
name?: string,
|
||
description?: string,
|
||
): Promise<AgentListItem> {
|
||
if (!SEMANTIC_ID_PATTERN.test(agentId)) {
|
||
throw new HttpError(
|
||
400,
|
||
"invalid_agent_id",
|
||
`Agent id must be 2–64 characters: ${SEMANTIC_ID_RULE}.`,
|
||
);
|
||
}
|
||
const taken =
|
||
this.agents.exists(projectId, agentId) ||
|
||
(await fs.stat(agentDir(this.root, projectId, agentId)).then(
|
||
() => true,
|
||
() => false,
|
||
));
|
||
if (taken) {
|
||
throw new HttpError(409, "agent_exists", `Agent id is already taken: ${agentId}.`);
|
||
}
|
||
const displayName = name ?? agentId;
|
||
await coreCreateAgent({ root: this.root, projectId, agentId });
|
||
try {
|
||
await this.agentConfig.updateConfig(projectId, agentId, {
|
||
config: { name: displayName, ...(description !== undefined ? { description } : {}) },
|
||
});
|
||
} catch (err) {
|
||
// If initialization fails partway through, clean up the directory: an orphaned
|
||
// directory would make retries with this agent id 409 forever.
|
||
await fs
|
||
.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true })
|
||
.catch(() => {});
|
||
throw err;
|
||
}
|
||
const createdAt = new Date().toISOString();
|
||
this.agents.insertOrIgnore({ projectId, agentId, createdAt });
|
||
// The init template ships with a default toolset and version number; read back the actual values.
|
||
const meta = await this.agentConfig.readCardMeta(projectId, agentId);
|
||
return {
|
||
agentId,
|
||
name: displayName,
|
||
...(description !== undefined ? { description } : {}),
|
||
createdAt,
|
||
updatedAt: createdAt,
|
||
toolCount: meta.toolCount,
|
||
version: meta.version,
|
||
vaultKeyCount: 0,
|
||
scheduleCount: 0,
|
||
// Read the real count: coreCreateAgent seeds the default skill set for default_agent.
|
||
skillCount: await this.skillCount(projectId, agentId),
|
||
};
|
||
}
|
||
}
|