Q40: tasks choose a writing mode; house style applies to conversational work only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
645ce686bb
commit
7be072f8c5
18 files changed
+119
-41
No files matched your search
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -28,6 +28,10 @@ export const zh: Record<string, string> = {
|
||||
"Advanced": "高级",
|
||||
"Integrations": "集成密钥",
|
||||
"Document": "文档",
|
||||
"Writing": "写作方式",
|
||||
"Conversational (house style)": "日常沟通(公司文风)",
|
||||
"Technical spec": "技术规格",
|
||||
"Structured output": "结构化输出",
|
||||
"Applies to": "适用范围",
|
||||
"Category": "类别",
|
||||
"Content": "内容",
|
||||
|
||||
@@ -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) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Writing")}>
|
||||
<select className="input" name="writingMode" defaultValue={WRITING_MODE.CONVERSATIONAL}>
|
||||
{WRITING_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" name="requiresFilesystem" /> {t("Requires workspace access")}
|
||||
</label>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> = T[keyof T];
|
||||
export type WritingMode = ValueOf<typeof WRITING_MODE>;
|
||||
export type TaskStatus = ValueOf<typeof TASK_STATUS>;
|
||||
export type ApprovalState = ValueOf<typeof APPROVAL_STATE>;
|
||||
export type RiskLevel = ValueOf<typeof RISK_LEVEL>;
|
||||
@@ -94,6 +97,7 @@ export interface TaskRecord {
|
||||
priority: TaskPriority;
|
||||
parentTaskId: string | null;
|
||||
requiresFilesystem: boolean;
|
||||
writingMode: WritingMode;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user