Q173: agents work in the company's or task's language, not the display language

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 04:40:52 -04:00
1 parent 538d834647
commit 3f14e3f706
36 files changed
+268 -105

No files matched your search

+3 -1
View File
@@ -1,5 +1,5 @@
import type { DatabaseSync } from "node:sqlite";
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, WRITING_MODE, type RiskLevel, type WritingMode } from "../../shared/domain";
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, WRITING_MODE, type RiskLevel, type WorkLanguage, 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";
@@ -364,6 +364,8 @@ function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord
priority: TASK_PRIORITY.NORMAL,
requiresFilesystem: args.requires_filesystem === true,
writingMode,
// Delegated work keeps the language its parent task was set to.
workLanguage: ctx.taskId ? (deps.db.prepare("select work_language from tasks where id = ?").get(ctx.taskId) as { work_language: WorkLanguage | null } | undefined)?.work_language ?? null : null,
});
deps.db.prepare("update tasks set handoff_json = ?, parent_task_id = ? where id = ?")
.run(JSON.stringify(handoff), parentTaskId, taskId);
+6 -6
View File
@@ -7,22 +7,22 @@ export function updateOrganizationProfile(db: DatabaseSync, input: UpdateOrganiz
const name = input.name.trim();
if (!name) throw new Error("The company needs a name.");
const before = db.prepare(`
select name, description, mission, goal, execution_mode, auto_approve_hires, review_deliverables from organizations where id = ?
`).get(input.orgId) as { name: string; description: string; mission: string; goal: string; execution_mode: string; auto_approve_hires: number; review_deliverables: number } | undefined;
select name, description, mission, goal, execution_mode, auto_approve_hires, review_deliverables, work_language from organizations where id = ?
`).get(input.orgId) as { name: string; description: string; mission: string; goal: string; execution_mode: string; auto_approve_hires: number; review_deliverables: number; work_language: string } | undefined;
if (!before) throw new Error("Organization was not found.");
const next = {
name, description: input.description.trim(), mission: input.mission.trim(), goal: input.goal.trim(),
executionMode: input.executionMode, autoApproveHires: input.autoApproveHires, reviewDeliverables: input.reviewDeliverables,
executionMode: input.executionMode, autoApproveHires: input.autoApproveHires, reviewDeliverables: input.reviewDeliverables, workLanguage: input.workLanguage,
};
const previous = {
name: before.name, description: before.description, mission: before.mission, goal: before.goal,
executionMode: before.execution_mode, autoApproveHires: before.auto_approve_hires === 1, reviewDeliverables: before.review_deliverables === 1,
executionMode: before.execution_mode, autoApproveHires: before.auto_approve_hires === 1, reviewDeliverables: before.review_deliverables === 1, workLanguage: before.work_language,
};
const changed = (Object.keys(next) as Array<keyof typeof next>).filter((key) => next[key] !== previous[key]);
db.prepare(`
update organizations set name = ?, description = ?, mission = ?, goal = ?, execution_mode = ?, auto_approve_hires = ?, review_deliverables = ?
update organizations set name = ?, description = ?, mission = ?, goal = ?, execution_mode = ?, auto_approve_hires = ?, review_deliverables = ?, work_language = ?
where id = ?
`).run(next.name, next.description, next.mission, next.goal, next.executionMode, next.autoApproveHires ? 1 : 0, next.reviewDeliverables ? 1 : 0, input.orgId);
`).run(next.name, next.description, next.mission, next.goal, next.executionMode, next.autoApproveHires ? 1 : 0, next.reviewDeliverables ? 1 : 0, next.workLanguage, input.orgId);
console.info(`[organization] ${input.orgId} updated: ${changed.join(", ") || "nothing changed"}`);
new AuditService(db).record({ orgId: input.orgId, entityType: "organization", entityId: input.orgId, actorType: "employee", actorId: null, action: "updated", payload: { changed, ...Object.fromEntries(changed.map((key) => [key, next[key]])) } });
}
+3 -2
View File
@@ -239,9 +239,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, writing_mode, origin, created_at, updated_at
project_id, goal_id, workspace_id, priority, requires_filesystem, writing_mode, origin, work_language, created_at, updated_at
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
@@ -257,6 +257,7 @@ export class OrganizationService {
input.requiresFilesystem ? 1 : 0,
input.writingMode ?? WRITING_MODE.CONVERSATIONAL,
input.origin ?? null,
input.workLanguage ?? null,
now,
now,
);
+8 -22
View File
@@ -1,3 +1,4 @@
import type { WorkLanguage } from "../../shared/domain";
import { existsSync, readFileSync } from "node:fs";
import type { DatabaseSync } from "node:sqlite";
import type { BindSkillInput, ImportSkillDirectoryInput, ResolveAgentSkillsInput, RunSkillTestsInput } from "../../shared/commands";
@@ -15,13 +16,7 @@ function slugify(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
/**
* House-style humanizer skills, one per app language. Every agent carries
* the variant matching the current UI language on every run, CEO included.
* They are a resolution rule, not a binding row, so no creation path — hire
* tool, onboarding, tests, raw SQL — can produce an agent without one.
* Both variants are protected from grant/revoke.
*/
/** House-style humanizer skills, one per working language: every agent carries its task's variant by rule, never by grant. */
export const HUMANIZER_SLUG_EN = "humanizer";
export const HUMANIZER_SLUG_ZH = "humanizer-zh";
export const FORCED_SKILL_SLUGS = [HUMANIZER_SLUG_EN, HUMANIZER_SLUG_ZH] as const;
@@ -243,21 +238,17 @@ 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, houseStyle: boolean): boolean {
if (houseStyle && this.forcedSkillRows(orgId).length > 0) return true;
hasRuntimeSkills(orgId: string, agentId: string, houseStyle: boolean, language: WorkLanguage): boolean {
if (houseStyle && this.forcedSkillRows(orgId, language).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
`).get(orgId, agentId));
}
/**
* The single forced house-style skill for the current app language:
* queries both humanizer variants and keeps the best match by preference
* order (language-matched first, other variant as fallback).
*/
private forcedSkillRows(orgId: string): RuntimeSkillRow[] {
const candidates = forcedSkillSlugCandidates(this.appLanguage());
/** The forced house-style skill for the working language, falling back to the other variant. */
private forcedSkillRows(orgId: string, language: WorkLanguage): RuntimeSkillRow[] {
const candidates = forcedSkillSlugCandidates(language);
const rows = this.db.prepare(`
select
sd.id as skill_id,
@@ -280,11 +271,6 @@ export class SkillService {
return [];
}
private appLanguage(): string {
const row = this.db.prepare("select language from app_state where id = 1").get() as { language: string } | undefined;
return row?.language === "zh" ? "zh" : "en";
}
/**
* Freezes the exact skill set this run carries: forced house-style skills
* first (identical for everyone, CEO included), then the agent's enabled
@@ -293,7 +279,7 @@ export class SkillService {
* prompt instead of silently overflowing the model's context.
*/
resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string {
const forcedRows = input.houseStyle ? this.forcedSkillRows(input.orgId) : [];
const forcedRows = input.houseStyle ? this.forcedSkillRows(input.orgId, input.language) : [];
const boundRows = this.db.prepare(`
select
sb.skill_id as skill_id,
+7 -10
View File
@@ -1,7 +1,8 @@
import { taskWorkLanguage } from "./work-language";
import { closeChatWakeTask } from "./chat-wake-close";
import type { DatabaseSync } from "node:sqlite";
import type { RunTaskInput } from "../../shared/commands";
import { TASK_STATUS, WRITING_MODE } from "../../shared/domain";
import { TASK_STATUS, WRITING_MODE, type WorkLanguage } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
import { ProviderService } from "./provider-service";
@@ -186,7 +187,7 @@ export class TaskExecutionService {
.run(effectiveModel(agent.model_id, provider.model), provider.credential_id, runId);
}
const runModel = pinned ?? effectiveModel(agent.model_id, provider.model);
skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens), `${task.title}\n${task.description}`, { providerKey: provider.provider_key, model: runModel }, task.writing_mode === WRITING_MODE.CONVERSATIONAL);
skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens), `${task.title}\n${task.description}`, { providerKey: provider.provider_key, model: runModel }, task.writing_mode === WRITING_MODE.CONVERSATIONAL, taskWorkLanguage(this.db, orgId, task.id));
// Watermark for reply delivery and mid-run message polling.
const runStartedAt = new Date().toISOString();
const chatContext = loadChatWakeContext(this.db, orgId, agent.id, isolation.secretScopeJson);
@@ -207,7 +208,7 @@ export class TaskExecutionService {
const connectorNames = new ConnectorGrantsService(this.db)
.toolkitsForAgent(orgId, agent.id)
.map(connectorDisplayName);
const language = this.appLanguage();
const language = taskWorkLanguage(this.db, orgId, task.id);
// Baseline taken before any await, so a goal closed while tools load still reaches the session.
const goalChanges = createGoalChangePoller(this.db, task.goal_id);
const externalTools = loadComposioTools(this.db, this.providers.integrationKeys, orgId, agent.id);
@@ -426,10 +427,6 @@ export class TaskExecutionService {
return row.name;
}
private appLanguage(): "en" | "zh" {
const row = this.db.prepare("select language from app_state where id = 1").get() as { language: string } | undefined;
return row?.language === "zh" ? "zh" : "en";
}
private completeRun(orgId: string, runId: string, providerRequestId: string | null, checkpoint: boolean): void {
this.db.prepare(`
@@ -477,9 +474,9 @@ export class TaskExecutionService {
`).run(TASK_RUN_STATE.FAILED, providerRequestId, new Date().toISOString(), errorMessage, failureKind, runId, orgId);
}
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 });
private resolveSkillRuntimeSnapshot(orgId: string, agentId: string, runId: string, maxTokens: number, taskText: string, runModel: RunModel, houseStyle: boolean, language: WorkLanguage): SkillRuntimeSnapshotRow | null {
if (!this.skills.hasRuntimeSkills(orgId, agentId, houseStyle, language)) return null;
const snapshotId = this.skills.resolveRuntimeSnapshot({ orgId, agentId, runId, maxTokens, taskText, runModel, houseStyle, language });
const snapshot = this.db.prepare(`
select id, compiled_prompt, tools_manifest, source_revision_ids
from skill_runtime_snapshots
+2 -2
View File
@@ -55,12 +55,12 @@ export class TaskMutations {
}
const result = this.db.prepare(`
update tasks set title = ?, description = ?, assignee_agent_id = ?, risk = ?, priority = ?, parent_task_id = ?,
project_id = ?, goal_id = ?, workspace_id = ?, requires_filesystem = ?, writing_mode = ?, updated_at = ?,
project_id = ?, goal_id = ?, workspace_id = ?, requires_filesystem = ?, writing_mode = ?, work_language = ?, updated_at = ?,
stalled_reason = null, enqueue_error = null, run_budget_floor = (select coalesce(max(rowid), 0) from task_runs)
where id = ? and org_id = ?
`).run(
input.title.trim(), input.description.trim(), input.assigneeAgentId, input.risk, input.priority, input.parentTaskId,
input.projectId, input.goalId, input.workspaceId, input.requiresFilesystem ? 1 : 0, input.writingMode, now, input.taskId, input.orgId,
input.projectId, input.goalId, input.workspaceId, input.requiresFilesystem ? 1 : 0, input.writingMode, input.workLanguage, now, input.taskId, input.orgId,
);
if (result.changes === 0) throw new Error("Task was not found.");
this.requestApproval(input.orgId, input.taskId);
+4 -4
View File
@@ -95,7 +95,7 @@ export interface AgentInstructionContext {
hasReports: boolean;
/** Display names of the connected toolkits this agent carries in this run. */
connectorNames: string[];
/** The operator's app language; agents speak it back. */
/** The task's working language (its own, else the company's). */
language: "en" | "zh";
}
@@ -112,9 +112,9 @@ export function buildAgentInstructions(input: AgentInstructionContext): string {
roleVoice(input),
"Work like a sharp person: take in what is actually in front of you, decide the smallest real next step, do it, then tell whoever needs to know. Do not narrate busywork.",
`Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name (set requires_filesystem for engineering work that must change code or run commands), update_task_status tracks it, list_files and read_file explore the company workspace, write_deliverable produces real files, run_command builds and tests for real on filesystem tasks, create_document writes company documents (PRDs, specs, memos, reports) and list_documents, read_document, update_document find and revise them, search_knowledge and record_knowledge read and grow what the company has learned, read_messages catches you up on what teammates said, and send_message is how you talk to everyone. web_search and fetch_url reach the live web — look up APIs, docs, and facts instead of guessing them.`,
...(input.language === "zh"
? ["公司的工作语言是简体中文:你写的每一条消息、任务标题与描述、文档和交付物都用中文表达(代码、命令、API 名称等技术标识保持原样)。工具名称和工具参数的字段名仍按定义使用英文。"]
: []),
input.language === "zh"
? "公司的工作语言是简体中文:你写的每一条消息、任务标题与描述、文档和交付物都用中文表达(代码、命令、API 名称等技术标识保持原样)。工具名称和工具参数的字段名仍按定义使用英文。"
: "The working language is English: write every message, task title and description, document and deliverable in English (code, commands and API names stay as they are), even when the material you read is in another language.",
capabilityBriefing(input),
"Talk like a person on a team, not a status bot: address teammates by @name, say what you genuinely think, ask when you are unsure, and reply when someone asks you something. Before starting work, check your messages with read_messages to see if anyone needs you. Post where the work lives — 'engineering', 'product', or otherwise 'general' — and keep a back-and-forth in one thread by passing the thread_id a previous send_message returned. Hand work to a teammate with /handoff @Name and the file_id write_deliverable gives you.",
].join("\n");
+13
View File
@@ -0,0 +1,13 @@
import type { DatabaseSync } from "node:sqlite";
import type { WorkLanguage } from "../../shared/domain";
/** The language agents work in on a task: the task's own, else its company's; the app's display language plays no part. */
export function taskWorkLanguage(db: DatabaseSync, orgId: string, taskId: string): WorkLanguage {
const row = db.prepare(`
select coalesce(t.work_language, o.work_language) as language
from tasks t join organizations o on o.id = t.org_id
where t.id = ? and t.org_id = ?
`).get(taskId, orgId) as { language: WorkLanguage } | undefined;
if (!row) throw new Error("Task was not found.");
return row.language;
}