Make the hire_agent department contract explicit
The CEO's first run hired five reports into departments "general" and "engineering"; the hiring-policy lookup was exact-match against the seeded names (Executive, Engineering, ...), so every hire failed and the kickoff produced nothing. The tool schema now enumerates the canonical departments, the policy lookup matches case-insensitively and stores the canonical name on the agent, and an unknown department error lists the valid choices so the model can self-correct.
This commit is contained in:
1 parent
2dd4f3976a
commit
d6d6656428
4 files changed
+63
-12
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, type RiskLevel } from "../../shared/domain";
|
||||
import { ORGANIZATION_DEPARTMENTS } from "./org-governance-service";
|
||||
import type { OrganizationService } from "./organization-service";
|
||||
import type { ChatService } from "./chat-service";
|
||||
import { writeDeliverable } from "./deliverable-writer";
|
||||
@@ -38,7 +39,7 @@ export const AGENT_TOOLS: ToolDefinition[] = [
|
||||
properties: {
|
||||
name: { type: "string", description: "Full name of the new agent." },
|
||||
role: { type: "string", description: "Their role/title, e.g. 'Growth Marketer'." },
|
||||
department: { type: "string", description: "Department, e.g. 'Marketing' or 'Operations'." },
|
||||
department: { type: "string", enum: [...ORGANIZATION_DEPARTMENTS], description: "The company department the hire belongs to." },
|
||||
model: { type: "string", description: "Optional model id; defaults to your own model." },
|
||||
},
|
||||
required: ["name", "role", "department"],
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { OperationsService } from "./operations-service";
|
||||
|
||||
const DEFAULT_DEPARTMENTS = ["Executive", "Engineering", "Product", "Design", "Marketing", "Operations", "Support"] as const;
|
||||
export const ORGANIZATION_DEPARTMENTS = ["Executive", "Engineering", "Product", "Design", "Marketing", "Operations", "Support"] as const;
|
||||
const RISK_RANK: Record<RiskLevel, number> = { low: 0, medium: 1, high: 2 };
|
||||
|
||||
export class OrgGovernanceService {
|
||||
@@ -18,7 +18,7 @@ export class OrgGovernanceService {
|
||||
}
|
||||
|
||||
seedOrganizationGovernance(orgId: string, boardEmployeeId: string, now: string): void {
|
||||
for (const department of DEFAULT_DEPARTMENTS) this.seedDepartmentPolicy(orgId, department, now);
|
||||
for (const department of ORGANIZATION_DEPARTMENTS) this.seedDepartmentPolicy(orgId, department, now);
|
||||
this.db.prepare(`
|
||||
insert into approval_routing_rules(id, org_id, kind, minimum_risk, route_to_type, route_to_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -27,14 +27,19 @@ export class OrgGovernanceService {
|
||||
}
|
||||
|
||||
enforceHiringPolicy(input: CreateAgentInput): HiringPolicyDecision {
|
||||
const departmentName = input.department.trim();
|
||||
if (!departmentName) throw new Error("Department is required.");
|
||||
const requested = input.department.trim();
|
||||
if (!requested) throw new Error("Department is required.");
|
||||
const policy = this.db.prepare(`
|
||||
select max_headcount, monthly_budget_limit_cents, require_approval_above_cents
|
||||
select department_name, max_headcount, monthly_budget_limit_cents, require_approval_above_cents
|
||||
from hiring_policies
|
||||
where org_id = ? and department_name = ?
|
||||
`).get(input.orgId, departmentName) as HiringPolicyRow | undefined;
|
||||
if (!policy) throw new Error("Hiring policy is required for department.");
|
||||
where org_id = ? and lower(department_name) = lower(?)
|
||||
`).get(input.orgId, requested) as HiringPolicyRow | undefined;
|
||||
if (!policy) {
|
||||
const known = this.db.prepare("select department_name from hiring_policies where org_id = ? order by department_name asc")
|
||||
.all(input.orgId) as Array<{ department_name: string }>;
|
||||
throw new Error(`Unknown department "${requested}". Departments: ${known.map((row) => row.department_name).join(", ")}.`);
|
||||
}
|
||||
const departmentName = policy.department_name;
|
||||
const usage = this.db.prepare(`
|
||||
select count(*) as headcount, coalesce(sum(budget_cents), 0) as budget_cents
|
||||
from agents
|
||||
@@ -44,7 +49,7 @@ export class OrgGovernanceService {
|
||||
if (usage.budget_cents + input.budgetCents > policy.monthly_budget_limit_cents) {
|
||||
throw new Error("Department monthly budget limit has been reached.");
|
||||
}
|
||||
return { reviewRequired: input.budgetCents > policy.require_approval_above_cents };
|
||||
return { reviewRequired: input.budgetCents > policy.require_approval_above_cents, departmentName };
|
||||
}
|
||||
|
||||
routeApproval(input: { orgId: string; approvalId: string; kind: string; risk: RiskLevel }): void {
|
||||
@@ -121,6 +126,7 @@ export class OrgGovernanceService {
|
||||
}
|
||||
|
||||
interface HiringPolicyRow {
|
||||
department_name: string;
|
||||
max_headcount: number;
|
||||
monthly_budget_limit_cents: number;
|
||||
require_approval_above_cents: number;
|
||||
@@ -128,6 +134,8 @@ interface HiringPolicyRow {
|
||||
|
||||
interface HiringPolicyDecision {
|
||||
reviewRequired: boolean;
|
||||
/** Canonical department name from the policy table (the input may differ in case). */
|
||||
departmentName: string;
|
||||
}
|
||||
|
||||
interface ApprovalRoutingRuleRow {
|
||||
|
||||
@@ -161,7 +161,7 @@ export class OrganizationService {
|
||||
input.orgId,
|
||||
input.name.trim(),
|
||||
input.role.trim(),
|
||||
input.department.trim(),
|
||||
hiringPolicy.departmentName,
|
||||
input.modelProviderId,
|
||||
input.modelId,
|
||||
input.budgetCents,
|
||||
@@ -182,7 +182,7 @@ export class OrganizationService {
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { role: input.role, department: input.department },
|
||||
payload: { role: input.role, department: hiringPolicy.departmentName },
|
||||
});
|
||||
this.addAgentToWorkspaceSpaces(input.orgId, id);
|
||||
this.identityAccess.createAgentServiceIdentity(input.orgId, id, input.name, now);
|
||||
|
||||
@@ -707,4 +707,46 @@ describe("domain services", () => {
|
||||
expect(ownedKickoff.assignee_agent_id).toBe(ownerId);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("hiring normalizes department case and rejects unknown departments with guidance", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
});
|
||||
|
||||
let agentId = "";
|
||||
connection.transaction(() => {
|
||||
agentId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Zhang Hao",
|
||||
role: "Algorithm Lead",
|
||||
department: "engineering",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 25000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
});
|
||||
const hired = connection.db.prepare("select department from agents where id = ?").get(agentId) as { department: string };
|
||||
expect(hired.department).toBe("Engineering");
|
||||
|
||||
expect(() => connection.transaction(() => organization.createAgent({
|
||||
orgId,
|
||||
name: "Chen Wei",
|
||||
role: "Research Lead",
|
||||
department: "general",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 25000,
|
||||
managerAgentId: null,
|
||||
}))).toThrow(/Unknown department "general"\. Departments: .*Engineering.*Executive/);
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user