Sessions are already agent-scoped post-v34, but the code kept routing
rotation / accumulation / handoff through the task indirection. Heartbeat
runs (no task) silently skipped all three, letting taskless transcripts
grow past the model's context window — the 7M-token explosion.
- DB adds getSessionById(sessionId) — direct lookup bypassing
tasks.active_session_id
- session-management-service: generateHandoff(session) is now agent-scoped,
walking recent runs across tasks + heartbeats; getHandoffForAgent(agentId)
is the canonical lookup, getHandoffContext(taskId) is a thin shim for
callers that still hold a task id
- turn-context.ts::resolveSession drops the `if (!task)` early return:
rotation evaluation runs on the agent session unconditionally
- run-finished-helpers.ts::accumulateAndRotateSession drops the `!task`
gate: tokens accumulate and rotation fires for every run, keyed on the
agent session, not the task
- Rotation evaluator contract: evaluatePreRunSessionRotation(agentId,
connectorId) → { rotated, handoff, sessionId }; old (taskId, connectorId)
signature removed
IPC contract tightening: ipc-support::emitEvent becomes a best-effort
sink. "Render frame was disposed" during renderer reload is a lifecycle
signal, not a logic error; silently drop it instead of letting it abort
downstream event emissions.
Artifact projection: pi's write/edit tool results now upsert a
`work_products` row via a new agent-runtime/artifact-projector.ts. No
more agents writing 54 files into the workspace that the UI never sees.
Migration v40 relaxes work_products.task_id to nullable so heartbeat
artifacts can register too.
File size: split agent-event-projection.ts out of runtime-bridge.ts to
keep the bridge under the 400-line cap after adding the projector hook.
Tests: 604 unit + integration green; generateHandoff callsites updated
for the single-argument signature; artifact-projector has pure-function
coverage via the projector module's own shape.
230 lines
8.2 KiB
TypeScript
230 lines
8.2 KiB
TypeScript
/**
|
|
* Role-classification helpers used across prompt construction and skill
|
|
* auto-assignment. Historical keyword match remains here for backfill and
|
|
* UI-free fallback; typed `agent.roleLevel` is the source of truth once the
|
|
* column is populated.
|
|
*
|
|
* Matching uses word-boundary tokenization instead of substring search,
|
|
* so "lead" does not match "cleaner" or "leaderboard".
|
|
*/
|
|
import type { AgentRoleLevel, TaskDomain, TaskTier } from "@shared/types";
|
|
import { isLeadershipLevel } from "@shared/types";
|
|
|
|
export const LEADERSHIP_ROLE_KEYWORDS = [
|
|
"chief",
|
|
"cto",
|
|
"cmo",
|
|
"coo",
|
|
"cfo",
|
|
"ceo",
|
|
"vp",
|
|
"vice",
|
|
"president",
|
|
"head",
|
|
"director",
|
|
"manager",
|
|
"lead",
|
|
] as const;
|
|
|
|
function tokenize(text: string): string[] {
|
|
// Unicode-safe tokenization. Matches runs of letters/numbers across
|
|
// scripts so a Chinese title like "高级架构师" yields ["高级架构师"]
|
|
// rather than being stripped. English tokens still tokenize on word
|
|
// boundaries as before.
|
|
return text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
}
|
|
|
|
/**
|
|
* Check whether a keyword appears in the role/title text. For Latin
|
|
* keywords we use whole-token equality (so "lead" does not match
|
|
* "leaderboard"). For CJK keywords we fall back to substring containment
|
|
* because CJK text has no explicit word boundaries — "架构师" is a
|
|
* legitimate substring of "高级架构师" and should match.
|
|
*/
|
|
function matchesKeyword(loweredText: string, tokens: Set<string>, keyword: string): boolean {
|
|
if (/[\u4e00-\u9fff]/.test(keyword)) {
|
|
return loweredText.includes(keyword);
|
|
}
|
|
return tokens.has(keyword);
|
|
}
|
|
|
|
export function roleMatchesLeadershipKeywords(role: string, title?: string | null): boolean {
|
|
const tokens = new Set(tokenize(`${role} ${title ?? ""}`));
|
|
return LEADERSHIP_ROLE_KEYWORDS.some((keyword) => tokens.has(keyword));
|
|
}
|
|
|
|
/**
|
|
* One-shot inference from free-text role/title strings. Used when creating or
|
|
* backfilling an agent so `roleLevel` can be stored once and re-read cheaply.
|
|
* The most specific token wins; order here is most-senior → least.
|
|
*/
|
|
export function inferRoleLevel(role: string, title?: string | null): AgentRoleLevel {
|
|
const tokens = new Set(tokenize(`${role} ${title ?? ""}`));
|
|
if (tokens.has("ceo") || tokens.has("cto") || tokens.has("cmo") || tokens.has("coo") || tokens.has("cfo") || tokens.has("chief")) {
|
|
return "c_suite";
|
|
}
|
|
if (tokens.has("vp") || tokens.has("vice") || tokens.has("president")) return "vp";
|
|
if (tokens.has("director") || tokens.has("head")) return "director";
|
|
if (tokens.has("manager")) return "manager";
|
|
if (tokens.has("lead")) return "lead";
|
|
return "ic";
|
|
}
|
|
|
|
/** Canonical leadership check: typed enum wins, fallback to keyword match. */
|
|
export function agentIsLeader(agent: { roleLevel?: AgentRoleLevel | null; role: string; title: string | null }): boolean {
|
|
if (agent.roleLevel) return isLeadershipLevel(agent.roleLevel);
|
|
return roleMatchesLeadershipKeywords(agent.role, agent.title);
|
|
}
|
|
|
|
export interface TaskProfile {
|
|
domain: TaskDomain;
|
|
requiredTier: TaskTier;
|
|
}
|
|
|
|
/**
|
|
* Ordered keyword → domain table. First match wins; order matters because
|
|
* role strings can contain multiple cues ("research engineer" is primarily
|
|
* a researcher, not a coder). English keywords match on whole-token
|
|
* equality; CJK keywords (Chinese here) match on substring containment.
|
|
* The list is intentionally narrow — anything unmatched falls through to
|
|
* the role-level heuristic below.
|
|
*/
|
|
const DOMAIN_KEYWORDS: Array<{ keyword: string; domain: TaskDomain }> = [
|
|
// ── Reasoning-heavy ──
|
|
// English
|
|
{ keyword: "architect", domain: "reasoning" },
|
|
{ keyword: "reviewer", domain: "reasoning" },
|
|
{ keyword: "auditor", domain: "reasoning" },
|
|
{ keyword: "analyst", domain: "reasoning" },
|
|
{ keyword: "researcher", domain: "reasoning" },
|
|
{ keyword: "scientist", domain: "reasoning" },
|
|
{ keyword: "mathematician", domain: "reasoning" },
|
|
{ keyword: "economist", domain: "reasoning" },
|
|
{ keyword: "strategist", domain: "reasoning" },
|
|
{ keyword: "cto", domain: "reasoning" },
|
|
{ keyword: "cfo", domain: "reasoning" },
|
|
// Chinese (CJK keywords match via substring, so "高级架构师" hits 架构师)
|
|
{ keyword: "架构师", domain: "reasoning" },
|
|
{ keyword: "审查员", domain: "reasoning" },
|
|
{ keyword: "审计", domain: "reasoning" },
|
|
{ keyword: "分析师", domain: "reasoning" },
|
|
{ keyword: "研究员", domain: "reasoning" },
|
|
{ keyword: "科学家", domain: "reasoning" },
|
|
{ keyword: "数学家", domain: "reasoning" },
|
|
{ keyword: "经济学家", domain: "reasoning" },
|
|
{ keyword: "策略师", domain: "reasoning" },
|
|
{ keyword: "首席技术", domain: "reasoning" },
|
|
{ keyword: "首席财务", domain: "reasoning" },
|
|
|
|
// ── Coding-heavy ──
|
|
{ keyword: "engineer", domain: "coding" },
|
|
{ keyword: "developer", domain: "coding" },
|
|
{ keyword: "coder", domain: "coding" },
|
|
{ keyword: "programmer", domain: "coding" },
|
|
{ keyword: "devops", domain: "coding" },
|
|
{ keyword: "sre", domain: "coding" },
|
|
{ keyword: "工程师", domain: "coding" },
|
|
{ keyword: "开发", domain: "coding" },
|
|
{ keyword: "程序员", domain: "coding" },
|
|
{ keyword: "研发", domain: "coding" },
|
|
{ keyword: "运维", domain: "coding" },
|
|
|
|
// ── Writing-heavy: copy / marketing / content / design / PM / HR ──
|
|
{ keyword: "writer", domain: "writing" },
|
|
{ keyword: "copywriter", domain: "writing" },
|
|
{ keyword: "marketer", domain: "writing" },
|
|
{ keyword: "marketing", domain: "writing" },
|
|
{ keyword: "content", domain: "writing" },
|
|
{ keyword: "editor", domain: "writing" },
|
|
{ keyword: "pm", domain: "writing" },
|
|
{ keyword: "designer", domain: "writing" },
|
|
{ keyword: "hr", domain: "writing" },
|
|
{ keyword: "撰稿", domain: "writing" },
|
|
{ keyword: "文案", domain: "writing" },
|
|
{ keyword: "市场", domain: "writing" },
|
|
{ keyword: "营销", domain: "writing" },
|
|
{ keyword: "内容", domain: "writing" },
|
|
{ keyword: "编辑", domain: "writing" },
|
|
{ keyword: "设计师", domain: "writing" },
|
|
{ keyword: "产品经理", domain: "writing" },
|
|
{ keyword: "运营", domain: "writing" },
|
|
{ keyword: "人力资源", domain: "writing" },
|
|
{ keyword: "人事", domain: "writing" },
|
|
];
|
|
|
|
/**
|
|
* Keyword cues that hard-pin a role to the trivial tier regardless of the
|
|
* role-level enum. A data-entry clerk whose `roleLevel` is `ic` runs on a
|
|
* trivial-tier model even if the free-text title is long; an "intern
|
|
* director" (yes, these exist) gets reviewed by their title before their
|
|
* level. Mixed English + Chinese cues.
|
|
*/
|
|
const TRIVIAL_KEYWORDS = [
|
|
"intern",
|
|
"assistant",
|
|
"clerk",
|
|
"entry",
|
|
"junior",
|
|
"associate",
|
|
"实习",
|
|
"助理",
|
|
"初级",
|
|
"见习",
|
|
"文员",
|
|
] as const;
|
|
|
|
/**
|
|
* Map a role string + optional title to (domain, requiredTier). Both the
|
|
* domain and the tier are conservative defaults — the picker will still
|
|
* fall back to the CEO model if no enabled candidate clears the bar, so
|
|
* under-classifying here is always safe.
|
|
*
|
|
* The CEO / c_suite cases are handled by the caller (the dispatcher has
|
|
* access to `agent.roleLevel` and knows to pin CEO to the user-chosen
|
|
* model). This function treats every input as a subordinate.
|
|
*/
|
|
export function inferTaskProfile(
|
|
role: string,
|
|
title: string | null | undefined,
|
|
roleLevel?: AgentRoleLevel | null,
|
|
): TaskProfile {
|
|
const loweredText = `${role} ${title ?? ""}`.toLowerCase();
|
|
const tokens = new Set(tokenize(loweredText));
|
|
|
|
// 1. Domain by keyword — first match wins.
|
|
let domain: TaskDomain = "writing";
|
|
for (const entry of DOMAIN_KEYWORDS) {
|
|
if (matchesKeyword(loweredText, tokens, entry.keyword)) {
|
|
domain = entry.domain;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 2. Tier is pinned trivial by explicit cues; otherwise rides role level.
|
|
const trivialOverride = TRIVIAL_KEYWORDS.some((kw) => matchesKeyword(loweredText, tokens, kw));
|
|
let tier: TaskTier;
|
|
if (trivialOverride) {
|
|
tier = "trivial";
|
|
} else {
|
|
switch (roleLevel) {
|
|
case "c_suite":
|
|
case "vp":
|
|
case "director":
|
|
tier = "hard";
|
|
break;
|
|
case "manager":
|
|
case "lead":
|
|
tier = "standard";
|
|
break;
|
|
case "ic":
|
|
case null:
|
|
case undefined:
|
|
default:
|
|
tier = "standard";
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { domain, requiredTier: tier };
|
|
}
|