diff --git a/src/main/database/migrations/066-task-writing-mode.ts b/src/main/database/migrations/066-task-writing-mode.ts new file mode 100644 index 00000000..bab4afe0 --- /dev/null +++ b/src/main/database/migrations/066-task-writing-mode.ts @@ -0,0 +1,7 @@ +export const taskWritingModeSchema = ` + alter table tasks add column writing_mode text not null default 'conversational' + check (writing_mode in ('conversational', 'technical', 'structured')); + + insert into schema_migrations(version, name, applied_at) + values (66, 'task_writing_mode', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 181c35cd..721493c7 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -64,6 +64,7 @@ import { providerContextWindowSchema } from "./migrations/062-provider-context-w import { knowledgeScopeSchema } from "./migrations/063-knowledge-scope"; import { knowledgeRevisionsSchema } from "./migrations/064-knowledge-revisions"; import { documentVersionsSchema } from "./migrations/065-document-versions"; +import { taskWritingModeSchema } from "./migrations/066-task-writing-mode"; interface Migration { version: number; @@ -137,6 +138,7 @@ const MIGRATIONS: Migration[] = [ { version: 63, name: "knowledge_scope", sql: knowledgeScopeSchema }, { version: 64, name: "knowledge_revisions", sql: knowledgeRevisionsSchema }, { version: 65, name: "document_versions", sql: documentVersionsSchema }, + { version: 66, name: "task_writing_mode", sql: taskWritingModeSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/agent-skill-tools.ts b/src/main/services/agent-skill-tools.ts index 1cf55c4b..4a99f55d 100644 --- a/src/main/services/agent-skill-tools.ts +++ b/src/main/services/agent-skill-tools.ts @@ -30,7 +30,7 @@ export function listSkillsTool(deps: AgentToolDeps, ctx: AgentToolContext): stri const summary = skillSummary(skill.manifest_json); if (forced.has(skill.slug)) { const language = skill.slug === "humanizer-zh" ? "Chinese" : "English"; - return `- ${skill.name} (${skill.slug})${summary}: house style — carried automatically by every agent when the app language is ${language}`; + return `- ${skill.name} (${skill.slug})${summary}: house style — carried automatically by every agent on conversational tasks when the app language is ${language}`; } const holders = bindings .filter((binding) => binding.skill_id === skill.id) diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index d58e8bc1..f585d64b 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -1,5 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; -import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, type RiskLevel } from "../../shared/domain"; +import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, WRITING_MODE, type RiskLevel, type WritingMode } from "../../shared/domain"; import { ORGANIZATION_DEPARTMENTS } from "./org-governance-service"; import type { OrganizationService } from "./organization-service"; import { buildTaskHandoff, HANDOFF_TOOL_PROPERTIES } from "./agent-task-handoff"; @@ -15,7 +15,7 @@ import { KNOWLEDGE_TOOLS, recordKnowledgeTool, reviseKnowledgeTool, searchKnowle import { DOCUMENT_TOOLS, DOCUMENT_TYPES, listDocumentsTool, readDocumentTool, updateDocumentTool } from "./agent-document-tools"; import { sendMessageTool } from "./agent-send-message"; import { withSavepoint } from "../database/connection"; -import { optionalString, optionalStringArray, requireString } from "./agent-tool-args"; +import { optionalEnum, optionalString, optionalStringArray, requireString } from "./agent-tool-args"; import type { JsonRecord } from "./provider-http"; import type { ToolCall, ToolDefinition } from "./provider-tool-turn"; @@ -85,6 +85,11 @@ export const AGENT_TOOLS: ToolDefinition[] = [ type: "boolean", description: "Set true for engineering work that must change code or run commands (build, test) in the workspace. The assignee then gets run_command; the operator approves such tasks before they execute.", }, + writing_mode: { + type: "string", + enum: ["conversational", "technical", "structured"], + description: "How the output reads. conversational (default): plain human prose in house style. technical: specs, API docs, code reviews; precise, no house style. structured: output must match an exact format (JSON, table, template).", + }, ...HANDOFF_TOOL_PROPERTIES, }, required: ["title", "description"], @@ -337,6 +342,7 @@ function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord const assignee = assigneeName ? requireRunnableAgentByName(deps.db, ctx.orgId, assigneeName) : null; const handoff = buildTaskHandoff(deps.db, ctx.orgId, { taskId: ctx.taskId, threadId: ctx.sourceThreadId }, args); const parentTaskId = delegationParent(deps.db, ctx); + const writingMode = optionalEnum(args.writing_mode, "writing_mode", Object.values(WRITING_MODE), WRITING_MODE.CONVERSATIONAL) as WritingMode; const create = () => { const taskId = deps.organization.createTask({ orgId: ctx.orgId, @@ -350,6 +356,7 @@ function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord workspaceId: ctx.workspace?.id ?? null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: args.requires_filesystem === true, + writingMode, }); deps.db.prepare("update tasks set handoff_json = ?, parent_task_id = ? where id = ?") .run(JSON.stringify(handoff), parentTaskId, taskId); diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index edb5ab3f..211120d3 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -1,5 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; -import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, type ApprovalState } from "../../shared/domain"; +import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type ApprovalState } from "../../shared/domain"; import { createId } from "../../shared/ids"; import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands"; import { AuditService } from "./audit-service"; @@ -234,9 +234,9 @@ export class OrganizationService { this.db.prepare(` insert into tasks( id, org_id, title, description, status, assignee_agent_id, risk, - project_id, goal_id, workspace_id, priority, requires_filesystem, origin, created_at, updated_at + project_id, goal_id, workspace_id, priority, requires_filesystem, writing_mode, origin, created_at, updated_at ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, input.orgId, @@ -250,6 +250,7 @@ export class OrganizationService { input.workspaceId, input.priority, input.requiresFilesystem ? 1 : 0, + input.writingMode ?? WRITING_MODE.CONVERSATIONAL, input.origin ?? null, now, now, diff --git a/src/main/services/skill-service.ts b/src/main/services/skill-service.ts index dc1b22aa..aeba39cc 100644 --- a/src/main/services/skill-service.ts +++ b/src/main/services/skill-service.ts @@ -244,8 +244,8 @@ export class SkillService { * agent carries the forced house-style skill once the catalogue holds a * language variant, plus whatever bindings the CEO granted. */ - hasRuntimeSkills(orgId: string, agentId: string): boolean { - if (this.forcedSkillRows(orgId).length > 0) return true; + hasRuntimeSkills(orgId: string, agentId: string, houseStyle: boolean): boolean { + if (houseStyle && this.forcedSkillRows(orgId).length > 0) return true; return Boolean(this.db.prepare(` select sb.id from skill_bindings sb join skill_definitions sd on sd.id = sb.skill_id and sd.status = 'active' where sb.org_id = ? and sb.agent_id = ? and sb.enabled = 1 limit 1 @@ -294,7 +294,7 @@ export class SkillService { * prompt instead of silently overflowing the model's context. */ resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string { - const forcedRows = this.forcedSkillRows(input.orgId); + const forcedRows = input.houseStyle ? this.forcedSkillRows(input.orgId) : []; const boundRows = this.db.prepare(` select sb.skill_id as skill_id, diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index 83853437..fa023ef0 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; import type { RunTaskInput } from "../../shared/commands"; -import { TASK_STATUS } from "../../shared/domain"; +import { TASK_STATUS, WRITING_MODE } from "../../shared/domain"; import { isTransientProviderFailure } from "../../shared/provider-runtime-domain"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; @@ -148,7 +148,7 @@ export class TaskExecutionService { let providerRequestId: string | null = null; let skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null = null; try { - skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens), `${task.title}\n${task.description}`, { providerKey: provider.provider_key, model: provider.model }); + skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens), `${task.title}\n${task.description}`, { providerKey: provider.provider_key, model: provider.model }, task.writing_mode === WRITING_MODE.CONVERSATIONAL); // Watermark for reply delivery and mid-run message polling. const runStartedAt = new Date().toISOString(); const chatContext = loadChatWakeContext(this.db, orgId, agent.id, isolation.secretScopeJson); @@ -391,9 +391,9 @@ export class TaskExecutionService { `).run(TASK_RUN_STATE.FAILED, providerRequestId, new Date().toISOString(), errorMessage, runId, orgId); } - private resolveSkillRuntimeSnapshot(orgId: string, agentId: string, runId: string, maxTokens: number, taskText: string, runModel: RunModel): SkillRuntimeSnapshotRow | null { - if (!this.skills.hasRuntimeSkills(orgId, agentId)) return null; - const snapshotId = this.skills.resolveRuntimeSnapshot({ orgId, agentId, runId, maxTokens, taskText, runModel }); + private resolveSkillRuntimeSnapshot(orgId: string, agentId: string, runId: string, maxTokens: number, taskText: string, runModel: RunModel, houseStyle: boolean): SkillRuntimeSnapshotRow | null { + if (!this.skills.hasRuntimeSkills(orgId, agentId, houseStyle)) return null; + const snapshotId = this.skills.resolveRuntimeSnapshot({ orgId, agentId, runId, maxTokens, taskText, runModel, houseStyle }); const snapshot = this.db.prepare(` select id, compiled_prompt, tools_manifest, source_revision_ids from skill_runtime_snapshots diff --git a/src/main/services/task-prompts.ts b/src/main/services/task-prompts.ts index f8768797..08f8fa3d 100644 --- a/src/main/services/task-prompts.ts +++ b/src/main/services/task-prompts.ts @@ -1,3 +1,4 @@ +import { WRITING_MODE, type WritingMode } from "../../shared/domain"; import { canonicalMentionHandle } from "../../shared/mention-handles"; const RECORD_LEARNINGS_INSTRUCTION = @@ -16,6 +17,7 @@ export interface TaskExecutionTaskRow { workspace_id: string | null; priority: string; requires_filesystem: number; + writing_mode: WritingMode; handoff_json: string | null; origin: string | null; } @@ -132,7 +134,7 @@ function capabilityBriefing(input: AgentInstructionContext): string { ); if (input.isCeo) { lines.push( - "You are the CEO: you automatically carry every connected service, and access for everyone else is yours to manage — when you delegate work that needs an external service, grant it first with grant_connector and take it back with revoke_connector when done. The company also ships a built-in skill library: staff every hire with the skills their role needs by passing skills to hire_agent (several per person is normal), and adjust later with grant_skill / revoke_skill. You run only with skills granted to you too: grant yourself a playbook before you rely on it. A teammate without the grant cannot use the tool or skill, so grant before you assign. The humanizer skill is company house style — every agent, you included, carries it automatically and must write like a person, not like an AI.", + "You are the CEO: you automatically carry every connected service, and access for everyone else is yours to manage — when you delegate work that needs an external service, grant it first with grant_connector and take it back with revoke_connector when done. The company also ships a built-in skill library: staff every hire with the skills their role needs by passing skills to hire_agent (several per person is normal), and adjust later with grant_skill / revoke_skill. You run only with skills granted to you too: grant yourself a playbook before you rely on it. A teammate without the grant cannot use the tool or skill, so grant before you assign. The humanizer skill is company house style — every agent, you included, carries it on conversational work and must write like a person, not like an AI. For specs and API docs pass writing_mode technical to create_task; for output in an exact format pass structured; both skip house style.", ); } else if (input.connectorNames.length === 0) { lines.push( @@ -160,6 +162,17 @@ function roleVoice(input: AgentInstructionContext): string { return "You own your craft. Ask the question early instead of guessing, do the work properly, and when you are stuck for more than a few minutes say so in the thread instead of spinning in place."; } +/** The writing rule for technical and structured tasks, which run without house style. */ +export function writingModeLines(mode: WritingMode): string[] { + if (mode === WRITING_MODE.TECHNICAL) { + return ["", "Technical writing: this task's output is a spec or technical document. Be exact and complete: define terms, name types, units and edge cases, prefer lists and tables over prose, and never trade precision for tone."]; + } + if (mode === WRITING_MODE.STRUCTURED) { + return ["", "Structured output: the deliverable must match the requested format exactly (JSON, table, template). No prose around it, no extra fields, no commentary inside it."]; + } + return []; +} + function skillToolLines(toolsManifest: string, available: string[]): string[] { const { requiredTools } = JSON.parse(toolsManifest) as { requiredTools: string[] }; const missing = requiredTools.filter((tool) => !available.includes(tool)); @@ -216,6 +229,7 @@ export function buildTaskPrompt(input: { `Mounts: ${input.isolation.workspaceMountJson}`, ); } + lines.push(...writingModeLines(input.task.writing_mode)); lines.push(...input.handoffLines); lines.push(...input.knowledgeLines); lines.push("", `Secret scope: ${input.isolation.secretScopeJson}`); diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index b5de9d27..aed4e01e 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -28,6 +28,10 @@ export const zh: Record = { "Advanced": "高级", "Integrations": "集成密钥", "Document": "文档", + "Writing": "写作方式", + "Conversational (house style)": "日常沟通(公司文风)", + "Technical spec": "技术规格", + "Structured output": "结构化输出", "Applies to": "适用范围", "Category": "类别", "Content": "内容", diff --git a/src/renderer/src/pages/NewTaskDialog.tsx b/src/renderer/src/pages/NewTaskDialog.tsx index a5b82206..aaece3d5 100644 --- a/src/renderer/src/pages/NewTaskDialog.tsx +++ b/src/renderer/src/pages/NewTaskDialog.tsx @@ -1,8 +1,8 @@ import type { FormEvent } from "react"; import { Dialog } from "../shared/Dialog"; import { Field } from "../shared/ui"; -import type { AppSnapshot, RiskLevel, TaskPriority } from "../../../shared/domain"; -import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../../shared/domain"; +import type { AppSnapshot, RiskLevel, TaskPriority, WritingMode } from "../../../shared/domain"; +import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, WRITING_MODE } from "../../../shared/domain"; import type { SnapshotActions } from "../app/useSnapshot"; import { t } from "../i18n/i18n"; @@ -18,6 +18,12 @@ const PRIORITY_OPTIONS: ReadonlyArray<{ value: TaskPriority; label: string }> = { value: TASK_PRIORITY.HIGH, label: t("High") }, ]; +const WRITING_OPTIONS: ReadonlyArray<{ value: WritingMode; label: string }> = [ + { value: WRITING_MODE.CONVERSATIONAL, label: t("Conversational (house style)") }, + { value: WRITING_MODE.TECHNICAL, label: t("Technical spec") }, + { value: WRITING_MODE.STRUCTURED, label: t("Structured output") }, +]; + function value(form: FormData, key: string): string { return String(form.get(key) ?? "").trim(); } @@ -59,6 +65,7 @@ export function NewTaskDialog({ workspaceId: optional(form, "workspaceId"), priority: value(form, "priority") as TaskPriority, requiresFilesystem: form.get("requiresFilesystem") === "on", + writingMode: value(form, "writingMode") as WritingMode, })).then((result) => { if (result) onClose(); }); @@ -102,6 +109,11 @@ export function NewTaskDialog({ {PRIORITY_OPTIONS.map((option) => )} + + + diff --git a/src/shared/commands/organization.ts b/src/shared/commands/organization.ts index 940e314b..fc105ba9 100644 --- a/src/shared/commands/organization.ts +++ b/src/shared/commands/organization.ts @@ -1,4 +1,4 @@ -import type { ApprovalState, IdentitySource, PrincipalType, RiskLevel, RoleKey, TaskPriority, TaskStatus, UiLanguage, UiTheme } from "../domain"; +import type { ApprovalState, IdentitySource, PrincipalType, RiskLevel, RoleKey, TaskPriority, TaskStatus, UiLanguage, UiTheme, WritingMode } from "../domain"; export interface SetActiveOrganizationInput { orgId: string; @@ -86,6 +86,8 @@ export interface CreateTaskInput { workspaceId: string | null; priority: TaskPriority; requiresFilesystem: boolean; + /** Defaults to conversational. */ + writingMode?: WritingMode; /** Provenance: 'chat_wake' for mention-wake vehicles, 'goal_review' for CEO goal checkpoints, omitted for plain work. */ origin?: "chat_wake" | "goal_review"; } diff --git a/src/shared/commands/skills.ts b/src/shared/commands/skills.ts index 7acd7ce8..53f1a54a 100644 --- a/src/shared/commands/skills.ts +++ b/src/shared/commands/skills.ts @@ -25,6 +25,8 @@ export interface ResolveAgentSkillsInput { taskText: string; /** The run's provider and model, matched against each skill's compatible_models. */ runModel: { providerKey: string; model: string | null }; + /** False on technical and structured tasks, which skip the house-style skill. */ + houseStyle: boolean; } export interface RunSkillTestsInput { diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 58cce05c..e344a9f2 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -19,11 +19,14 @@ export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REV export const APPROVAL_STATE = { PENDING: "pending", APPROVED: "approved", DECLINED: "declined" } as const; export const RISK_LEVEL = { LOW: "low", MEDIUM: "medium", HIGH: "high" } as const; export const TASK_PRIORITY = { LOW: "low", NORMAL: "normal", HIGH: "high" } as const; +/** How a task's output should read: conversational prose carries house style; technical and structured do not. */ +export const WRITING_MODE = { CONVERSATIONAL: "conversational", TECHNICAL: "technical", STRUCTURED: "structured" } as const; export const PROVIDER_AUTH_TYPE = { API_KEY: "api_key", OAUTH_PKCE: "oauth_pkce", ACCOUNT_SUBSCRIPTION: "account_subscription", CLOUD: "cloud" } as const; export const SOCIAL_PLATFORM = { X: "x", LINKEDIN: "linkedin", YOUTUBE: "youtube", INSTAGRAM: "instagram" } as const; export const FILE_SCAN_STATE = { APPROVED: "approved", BLOCKED: "blocked" } as const; export type ValueOf = T[keyof T]; +export type WritingMode = ValueOf; export type TaskStatus = ValueOf; export type ApprovalState = ValueOf; export type RiskLevel = ValueOf; @@ -94,6 +97,7 @@ export interface TaskRecord { priority: TaskPriority; parentTaskId: string | null; requiresFilesystem: boolean; + writingMode: WritingMode; createdAt: string; updatedAt: string; } diff --git a/tests/integration/collaboration-flow.test.ts b/tests/integration/collaboration-flow.test.ts index 571f9288..36ca08fe 100644 --- a/tests/integration/collaboration-flow.test.ts +++ b/tests/integration/collaboration-flow.test.ts @@ -98,7 +98,7 @@ describe("collaboration integration flow", () => { revisionId = skills.importDirectory({ orgId, directoryPath: createSkillDirectory(), versionSemver: "1.0.0" }); skillId = (connection.db.prepare("select skill_id from skill_revisions where id = ?").get(revisionId) as { skill_id: string }).skill_id; skills.bind({ orgId, agentId, skillId, revisionId }); - skills.resolveRuntimeSnapshot({ orgId, agentId, runId: `run_${randomUUID().replaceAll("-", "")}`, maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" } }); + skills.resolveRuntimeSnapshot({ orgId, agentId, runId: `run_${randomUUID().replaceAll("-", "")}`, maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true }); suiteId = (connection.db.prepare("select id from eval_suites where org_id = ? and key = ?") .get(orgId, EVAL_SUITE_KEY.INTEGRATION) as { id: string }).id; evalRunId = sdlc.runEvalSuite({ orgId, suiteId }); diff --git a/tests/unit/builtin-skills.test.ts b/tests/unit/builtin-skills.test.ts index 362b112e..75ed9a20 100644 --- a/tests/unit/builtin-skills.test.ts +++ b/tests/unit/builtin-skills.test.ts @@ -171,7 +171,7 @@ describe("built-in skills", () => { h.skills.bind({ orgId: h.orgId, agentId, skillId: skill.id, revisionId: skill.revision_id }); }); const prompt = (runId: string) => { - const id = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId, maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" } })); + const id = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId, maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true })); return (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(id) as { compiled_prompt: string }).compiled_prompt; }; expect(prompt("run_before")).toContain("# Skill: Backend Architect"); @@ -265,7 +265,7 @@ describe("built-in skills", () => { expect(hired).toContain("Unknown skills skipped: no-such-skill"); const danaId = (h.connection.db.prepare("select id from agents where org_id = ? and name = 'Dana Lee'").get(h.orgId) as { id: string }).id; - const danaSnapshot = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId: danaId, runId: "run_dana", maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" } })); + const danaSnapshot = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId: danaId, runId: "run_dana", maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true })); const danaPrompt = (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(danaSnapshot) as { compiled_prompt: string }).compiled_prompt; expect(danaPrompt.indexOf("# Skill: humanizer")).toBe(0); expect(danaPrompt).toContain("# Skill: Backend Architect"); @@ -274,12 +274,19 @@ describe("built-in skills", () => { expect(danaPrompt.length).toBeLessThan(SKILL_TOKENS * 3 + 5_000); // The CEO carries humanizer too - forced by resolution, not by binding. - expect(h.skills.hasRuntimeSkills(h.orgId, ceoId)).toBe(true); - const ceoSnapshot = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId: ceoId, runId: "run_ceo", maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" } })); + expect(h.skills.hasRuntimeSkills(h.orgId, ceoId, true)).toBe(true); + const ceoSnapshot = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId: ceoId, runId: "run_ceo", maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true })); const ceoPrompt = (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(ceoSnapshot) as { compiled_prompt: string }).compiled_prompt; expect(ceoPrompt).toContain("# Skill: humanizer"); expect(ceoPrompt).not.toContain("# Skill: Backend Architect"); + // Technical and structured work drops house style; a CEO without grants then carries nothing. + expect(h.skills.hasRuntimeSkills(h.orgId, ceoId, false)).toBe(false); + const technicalSnapshot = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId: danaId, runId: "run_dana_spec", maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: false })); + const technicalPrompt = (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(technicalSnapshot) as { compiled_prompt: string }).compiled_prompt; + expect(technicalPrompt).not.toContain("# Skill: humanizer"); + expect(technicalPrompt.indexOf("# Skill: Backend Architect")).toBe(0); + // list_skills shows descriptions and marks the house-style skill. const listing = dispatchAgentTool(deps, ceoCtx, { id: "call_2", name: "list_skills", arguments: {} }); expect(listing).toContain("humanizer (humanizer)"); @@ -300,7 +307,7 @@ describe("built-in skills", () => { }); }); const promptFor = (runId: string) => { - const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId, maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" } })); + const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId, maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true })); return (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(snapshotId) as { compiled_prompt: string }).compiled_prompt; }; @@ -346,7 +353,7 @@ describe("built-in skills", () => { modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null, }); }); - const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId: "run_tiny", maxTokens: 10, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" } })); + const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId: "run_tiny", maxTokens: 10, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true })); const prompt = (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(snapshotId) as { compiled_prompt: string }).compiled_prompt; expect(prompt).not.toContain("# Skill:"); expect(prompt).toContain("# Skills omitted for size"); @@ -373,7 +380,7 @@ describe("built-in skills", () => { bindSkillSlugs({ db: h.connection.db, organization: h.organization, chat: new ChatService(h.connection.db) }, h.orgId, agentId, ["marketing-growth-hacker", "engineering-backend-architect"]); }); const compile = (runId: string, maxTokens: number, taskText: string) => { - const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId, maxTokens, taskText, runModel: { providerKey: "openai", model: "gpt-5.4" } })); + const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId, maxTokens, taskText, runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true })); return (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(snapshotId) as { compiled_prompt: string }).compiled_prompt; }; const parts = compile("run_full", 1_000_000, "").split("\n\n---\n\n"); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 7c91d12e..0aea20cc 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66]); connection.close(); }); @@ -186,7 +186,7 @@ describe("domain services", () => { skills.bind({ orgId, agentId, skillId: skill.id, revisionId }); rmSync(skillDir, { recursive: true, force: true }); skills.runTests({ orgId, revisionId }); - skills.resolveRuntimeSnapshot({ orgId, agentId, runId: "run-review-1", maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" } }); + skills.resolveRuntimeSnapshot({ orgId, agentId, runId: "run-review-1", maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true }); }); // Catalogue metadata ships in the snapshot; skill internals stay in the @@ -247,10 +247,10 @@ describe("domain services", () => { .get(orgA, "review-routes") as { id: string }).id; expect(() => skills.bind({ orgId: orgA, agentId: agentB, skillId, revisionId })).toThrow("Agent was not found."); skills.bind({ orgId: orgA, agentId: agentA, skillId, revisionId }); - const firstResolve = skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1", maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" } }); + const firstResolve = skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1", maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true }); // Idempotent per run: a recovered run reuses its frozen snapshot // instead of failing on the uniqueness guard. - expect(skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1", maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" } })).toBe(firstResolve); + expect(skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1", maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true })).toBe(firstResolve); expect(connection.db.prepare("select count(*) as n from skill_runtime_snapshots where run_id = 'run-review-1'").get()) .toEqual({ n: 1 }); }); diff --git a/tests/unit/skill-compatibility.test.ts b/tests/unit/skill-compatibility.test.ts index 3ccd9844..0ce8b89c 100644 --- a/tests/unit/skill-compatibility.test.ts +++ b/tests/unit/skill-compatibility.test.ts @@ -42,7 +42,7 @@ describe("skill compatibility", () => { skills.bind({ orgId, agentId, skillId: skill.id, revisionId }); }); const prompt = (runId: string, providerKey: string, model: string) => { - const id = connection.transaction(() => skills.resolveRuntimeSnapshot({ orgId, agentId, runId, maxTokens: 40_000, taskText: "", runModel: { providerKey, model } })); + const id = connection.transaction(() => skills.resolveRuntimeSnapshot({ orgId, agentId, runId, maxTokens: 40_000, taskText: "", runModel: { providerKey, model }, houseStyle: true })); return connection.db.prepare("select compiled_prompt, source_revision_ids from skill_runtime_snapshots where id = ?").get(id) as { compiled_prompt: string; source_revision_ids: string }; }; diff --git a/tests/unit/skill-grants.test.ts b/tests/unit/skill-grants.test.ts index 695286a8..2760e1b4 100644 --- a/tests/unit/skill-grants.test.ts +++ b/tests/unit/skill-grants.test.ts @@ -7,6 +7,7 @@ import { OrganizationService } from "../../src/main/services/organization-servic import { ChatService } from "../../src/main/services/chat-service"; import { SkillService } from "../../src/main/services/skill-service"; import { AGENT_TOOLS, dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools"; +import { writingModeLines } from "../../src/main/services/task-prompts"; function call(name: string, args: Record) { return { id: `call_${randomUUID()}`, name, arguments: args }; @@ -75,6 +76,21 @@ function importSkill(h: Harness, name: string, slug: string): void { } describe("skill grants", () => { + test("create_task records the writing mode, which decides house style and the writing rule", () => { + const h = harness(); + const created = dispatchAgentTool(h.deps, h.ceoCtx, call("create_task", { title: "API spec", description: "Write the spec.", writing_mode: "technical" })); + const taskId = /task_id=(tsk_\w+)/.exec(created)![1]; + expect(h.connection.db.prepare("select writing_mode from tasks where id = ?").get(taskId)).toEqual({ writing_mode: "technical" }); + const plain = dispatchAgentTool(h.deps, h.ceoCtx, call("create_task", { title: "Chat", description: "Say hi." })); + expect(h.connection.db.prepare("select writing_mode from tasks where id = ?").get(/task_id=(tsk_\w+)/.exec(plain)![1])).toEqual({ writing_mode: "conversational" }); + expect(dispatchAgentTool(h.deps, h.ceoCtx, call("create_task", { title: "X", description: "Y", writing_mode: "poetic" }))) + .toBe('ERROR: "writing_mode" must be one of: conversational, technical, structured.'); + expect(writingModeLines("conversational")).toEqual([]); + expect(writingModeLines("technical").join(" ")).toContain("Technical writing"); + expect(writingModeLines("structured").join(" ")).toContain("Structured output"); + h.connection.close(); + }); + test("the CEO runs only with granted skills, as the tool text says, and can grant itself", () => { const h = harness(); importSkill(h, "Release Gate", "release-gate"); @@ -82,10 +98,10 @@ describe("skill grants", () => { expect(listSkills?.description).not.toContain("every skill automatically"); expect(listSkills?.description).toContain("the CEO included"); const skills = new SkillService(h.connection.db, ""); - expect(skills.hasRuntimeSkills(h.orgId, h.ceoId)).toBe(false); + expect(skills.hasRuntimeSkills(h.orgId, h.ceoId, true)).toBe(false); expect(dispatchAgentTool(h.deps, h.ceoCtx, call("grant_skill", { agent_name: "CEO", skill: "release-gate" }))) .toBe("CEO now has the Release Gate skill; it joins their next run."); - expect(skills.hasRuntimeSkills(h.orgId, h.ceoId)).toBe(true); + expect(skills.hasRuntimeSkills(h.orgId, h.ceoId, true)).toBe(true); h.connection.close(); }); @@ -100,11 +116,11 @@ describe("skill grants", () => { expect(listing).toContain("Release Gate (release-gate): granted to Dana"); const skills = new SkillService(h.connection.db, ""); - expect(skills.hasRuntimeSkills(h.orgId, h.danaId)).toBe(true); + expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true)).toBe(true); const revoked = dispatchAgentTool(h.deps, h.ceoCtx, call("revoke_skill", { agent_name: "Dana", skill: "release-gate" })); expect(revoked).toContain("no longer has the Release Gate skill"); - expect(skills.hasRuntimeSkills(h.orgId, h.danaId)).toBe(false); + expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true)).toBe(false); }); test("staff cannot hand out skills", () => { @@ -112,7 +128,7 @@ describe("skill grants", () => { importSkill(h, "Release Gate", "release-gate"); const denied = dispatchAgentTool(h.deps, h.danaCtx, call("grant_skill", { agent_name: "Dana", skill: "release-gate" })); expect(denied).toContain("ERROR"); - expect(new SkillService(h.connection.db, "").hasRuntimeSkills(h.orgId, h.danaId)).toBe(false); + expect(new SkillService(h.connection.db, "").hasRuntimeSkills(h.orgId, h.danaId, true)).toBe(false); }); test("the humanizer house-style skill reaches every agent without bindings and cannot be revoked", () => { @@ -120,18 +136,18 @@ describe("skill grants", () => { const skills = new SkillService(h.connection.db, ""); // A non-forced skill grants nothing on its own - not even to the CEO. importSkill(h, "Release Gate", "release-gate"); - expect(skills.hasRuntimeSkills(h.orgId, h.ceoId)).toBe(false); - expect(skills.hasRuntimeSkills(h.orgId, h.danaId)).toBe(false); + expect(skills.hasRuntimeSkills(h.orgId, h.ceoId, true)).toBe(false); + expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true)).toBe(false); // Once the forced skill exists in the catalogue, everyone carries it. importSkill(h, "humanizer", "humanizer"); - expect(skills.hasRuntimeSkills(h.orgId, h.ceoId)).toBe(true); - expect(skills.hasRuntimeSkills(h.orgId, h.danaId)).toBe(true); + expect(skills.hasRuntimeSkills(h.orgId, h.ceoId, true)).toBe(true); + expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true)).toBe(true); // House style is not revocable, and granting it is a no-op with feedback. const revoked = dispatchAgentTool(h.deps, h.ceoCtx, call("revoke_skill", { agent_name: "Dana", skill: "humanizer" })); expect(revoked).toContain("ERROR"); expect(revoked).toContain("house style"); const granted = dispatchAgentTool(h.deps, h.ceoCtx, call("grant_skill", { agent_name: "Dana", skill: "humanizer" })); expect(granted).toContain("automatically"); - expect(skills.hasRuntimeSkills(h.orgId, h.danaId)).toBe(true); + expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true)).toBe(true); }); });