Compare commits
8 Commits
0a8790b088
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d0669a0878 | |||
| b9906f6047 | |||
| 8d933386d3 | |||
| 0b6117cd7e | |||
| fc5d8725f1 | |||
| b9a5bb6e80 | |||
| 0bf2e46212 | |||
| aeffbe65ee |
@@ -148,6 +148,20 @@ export class LegalApi {
|
|||||||
// Return static style guide reference
|
// Return static style guide reference
|
||||||
return "ראה skill-legal-decision/SKILL.md — מדריך סגנון מלא של דפנה תמיר";
|
return "ראה skill-legal-decision/SKILL.md — מדריך סגנון מלא של דפנה תמיר";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent conclusions of prior heartbeat runs across a case's issues (#220,
|
||||||
|
* "seance"). Lets a resuming agent read what earlier sessions concluded
|
||||||
|
* instead of re-deriving context from scratch.
|
||||||
|
*/
|
||||||
|
async getPredecessorForCase(
|
||||||
|
caseNumber: string,
|
||||||
|
limit = 5,
|
||||||
|
): Promise<PredecessorResponse> {
|
||||||
|
return this.request(
|
||||||
|
`/api/operations/cases/${encodeURIComponent(caseNumber)}/predecessor?limit=${limit}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -260,3 +274,21 @@ export interface QAResponse {
|
|||||||
}>;
|
}>;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PredecessorRun {
|
||||||
|
run_id: string;
|
||||||
|
status: string;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
error_code: string | null;
|
||||||
|
session_id: string | null;
|
||||||
|
agent_name: string | null;
|
||||||
|
identifier: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PredecessorResponse {
|
||||||
|
ok: boolean;
|
||||||
|
case_number: string;
|
||||||
|
runs: PredecessorRun[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -156,6 +156,23 @@ export default {
|
|||||||
description: "Get overall system processing status",
|
description: "Get overall system processing status",
|
||||||
parametersSchema: { type: "object" as const, properties: {} },
|
parametersSchema: { type: "object" as const, properties: {} },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
toolKey: "legal_predecessor_context",
|
||||||
|
name: "legal_predecessor_context",
|
||||||
|
displayName: "הקשר מריצות קודמות",
|
||||||
|
description:
|
||||||
|
"Recent conclusions from prior heartbeat runs on this case (the 'summary' each run left). Call this when RESUMING work on a case to see what earlier sessions already did/decided — instead of re-deriving context from scratch.",
|
||||||
|
parametersSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
case_number: {
|
||||||
|
type: "string" as const,
|
||||||
|
description: "Case number (e.g. 1043-02-26)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["case_number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
jobs: [
|
jobs: [
|
||||||
{
|
{
|
||||||
@@ -178,6 +195,13 @@ export default {
|
|||||||
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
|
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
|
||||||
schedule: "0 19 * * 0",
|
schedule: "0 19 * * 0",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
jobKey: "route-pending-comments",
|
||||||
|
displayName: "ערובת מסירת-הערות (sweep)",
|
||||||
|
description:
|
||||||
|
"sweep פיוס: מנתב כל הערת-משתמש שטרם נותבה ל-CEO (at-least-once), כדי שהערה לא תיפול גם אם ה-wakeup התבטל/התלכד",
|
||||||
|
schedule: "*/2 * * * *",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
webhooks: [
|
webhooks: [
|
||||||
{
|
{
|
||||||
|
|||||||
270
src/worker.ts
270
src/worker.ts
@@ -507,6 +507,40 @@ const plugin = definePlugin({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ctx.tools.register(
|
||||||
|
"legal_predecessor_context",
|
||||||
|
{
|
||||||
|
displayName: "הקשר מריצות קודמות",
|
||||||
|
description:
|
||||||
|
"Recent conclusions from prior heartbeat runs on this case (the 'summary' each run left). Call this when RESUMING work on a case to see what earlier sessions already did/decided — instead of re-deriving context from scratch. Provide the case number.",
|
||||||
|
parametersSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
case_number: {
|
||||||
|
type: "string",
|
||||||
|
description: "Case number (e.g. 1043-02-26)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["case_number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const { case_number } = params as { case_number: string };
|
||||||
|
const result = await api.getPredecessorForCase(case_number);
|
||||||
|
// Readable digest for the agent — each prior run's conclusion,
|
||||||
|
// newest-first, summaries trimmed to keep the wake context lean.
|
||||||
|
const lines = result.runs.map((r) => {
|
||||||
|
const summary = (r.summary ?? "").trim().slice(0, 600);
|
||||||
|
const when = r.finished_at ?? r.started_at ?? "";
|
||||||
|
return `▸ ${r.agent_name ?? "?"} · ${r.identifier ?? ""} · ${when} · ${r.status}\n${summary}`;
|
||||||
|
});
|
||||||
|
const content = lines.length
|
||||||
|
? lines.join("\n\n")
|
||||||
|
: "אין ריצות קודמות עם מסקנות לתיק זה.";
|
||||||
|
return { content, data: result };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ── Events ─────────────────────────────────────────────────────
|
// ── Events ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
ctx.events.on("issue.created", async (event) => {
|
ctx.events.on("issue.created", async (event) => {
|
||||||
@@ -535,6 +569,83 @@ const plugin = definePlugin({
|
|||||||
|
|
||||||
// Route user comments through CEO agent — per company
|
// Route user comments through CEO agent — per company
|
||||||
|
|
||||||
|
// Marker: the id of the last user comment already routed to the CEO for
|
||||||
|
// an issue. BOTH the event-driven route below and the reconciliation
|
||||||
|
// sweep (`route-pending-comments`) set it, so the sweep never re-routes a
|
||||||
|
// comment the fast path already handled. This is what turns best-effort
|
||||||
|
// event delivery into an at-least-once guarantee.
|
||||||
|
const LAST_ROUTED_COMMENT_KEY = "last-routed-comment-id";
|
||||||
|
|
||||||
|
async function markCommentRouted(
|
||||||
|
issueId: string,
|
||||||
|
commentId: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!commentId) return;
|
||||||
|
try {
|
||||||
|
await ctx.state.set(
|
||||||
|
{
|
||||||
|
scopeKind: "issue",
|
||||||
|
scopeId: issueId,
|
||||||
|
stateKey: LAST_ROUTED_COMMENT_KEY,
|
||||||
|
},
|
||||||
|
commentId,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn("Failed to persist last-routed-comment marker", {
|
||||||
|
issueId,
|
||||||
|
commentId,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke the company CEO to handle a user comment on an issue. Shared by
|
||||||
|
// the event-driven route and the reconciliation sweep. On success it
|
||||||
|
// stamps the marker so the comment is not routed twice. Returns true when
|
||||||
|
// the CEO was invoked.
|
||||||
|
async function routeCommentToCeo(args: {
|
||||||
|
issue: { id: string; title: string; identifier?: string | null };
|
||||||
|
companyId: string;
|
||||||
|
ceoAgentId: string;
|
||||||
|
commentBody: string;
|
||||||
|
commentId?: string;
|
||||||
|
source: "event" | "sweep";
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { issue, companyId, ceoAgentId, commentBody, commentId, source } =
|
||||||
|
args;
|
||||||
|
const promptBody = commentBody
|
||||||
|
? commentBody
|
||||||
|
: "(לא ניתן לקרוא את תוכן התגובה — קרא את ה-comments האחרונים על ה-issue ישירות)";
|
||||||
|
try {
|
||||||
|
const { runId } = await ctx.agents.invoke(ceoAgentId, companyId, {
|
||||||
|
prompt: [
|
||||||
|
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issue.id}):`,
|
||||||
|
"",
|
||||||
|
promptBody,
|
||||||
|
"",
|
||||||
|
`קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`,
|
||||||
|
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
|
||||||
|
].join("\n"),
|
||||||
|
reason: `user_commented_on_${issue.identifier || issue.id}`,
|
||||||
|
});
|
||||||
|
await markCommentRouted(issue.id, commentId);
|
||||||
|
ctx.logger.info("Routed user comment to CEO agent", {
|
||||||
|
issueId: issue.id,
|
||||||
|
commentId: commentId || null,
|
||||||
|
runId,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error("Failed to invoke CEO agent for comment routing", {
|
||||||
|
issueId: issue.id,
|
||||||
|
source,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.events.on("issue.comment.created", async (event) => {
|
ctx.events.on("issue.comment.created", async (event) => {
|
||||||
// Only intercept human comments — not agent comments (prevents loops)
|
// Only intercept human comments — not agent comments (prevents loops)
|
||||||
if (event.actorType !== "user") return;
|
if (event.actorType !== "user") return;
|
||||||
@@ -601,6 +712,9 @@ const plugin = definePlugin({
|
|||||||
"Comment reopened CEO-owned issue; native wake handles it, skipping plugin route",
|
"Comment reopened CEO-owned issue; native wake handles it, skipping plugin route",
|
||||||
{ issueId, ceoAgentId },
|
{ issueId, ceoAgentId },
|
||||||
);
|
);
|
||||||
|
// The comment IS being handled (natively) — stamp the marker so the
|
||||||
|
// reconciliation sweep does not re-route it a second time.
|
||||||
|
await markCommentRouted(issueId, commentId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,7 +731,16 @@ const plugin = definePlugin({
|
|||||||
const matched = commentId
|
const matched = commentId
|
||||||
? comments.find((c) => c.id === commentId)
|
? comments.find((c) => c.id === commentId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const latest = comments[comments.length - 1];
|
// Newest by createdAt — do NOT rely on listComments array order.
|
||||||
|
const latest = comments.reduce<(typeof comments)[number] | null>(
|
||||||
|
(acc, c) =>
|
||||||
|
!acc ||
|
||||||
|
new Date(c.createdAt).getTime() >
|
||||||
|
new Date(acc.createdAt).getTime()
|
||||||
|
? c
|
||||||
|
: acc,
|
||||||
|
null,
|
||||||
|
);
|
||||||
commentBody =
|
commentBody =
|
||||||
matched?.body ||
|
matched?.body ||
|
||||||
latest?.body ||
|
latest?.body ||
|
||||||
@@ -625,41 +748,25 @@ const plugin = definePlugin({
|
|||||||
"(לא ניתן לקרוא את התגובה)";
|
"(לא ניתן לקרוא את התגובה)";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.logger.warn("listComments failed, falling back to bodySnippet", {
|
ctx.logger.warn("listComments failed, falling back to bodySnippet", {
|
||||||
issueId, commentId, error: String(err),
|
issueId,
|
||||||
|
commentId,
|
||||||
|
error: String(err),
|
||||||
});
|
});
|
||||||
commentBody = payload?.bodySnippet ?? "";
|
commentBody = payload?.bodySnippet ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we still have no body, instruct CEO to read comments directly
|
// Route through the shared helper (also used by the sweep). It stamps
|
||||||
// rather than sending empty/error content as the instruction.
|
// the `last-routed-comment-id` marker on success so the reconciliation
|
||||||
const promptBody = commentBody
|
// sweep does not re-deliver this comment.
|
||||||
? commentBody
|
await routeCommentToCeo({
|
||||||
: "(לא ניתן לקרוא את תוכן התגובה — קרא את ה-comments האחרונים על ה-issue ישירות)";
|
issue,
|
||||||
|
companyId: event.companyId,
|
||||||
try {
|
ceoAgentId,
|
||||||
const { runId } = await ctx.agents.invoke(ceoAgentId, event.companyId, {
|
commentBody: commentBody ?? "",
|
||||||
prompt: [
|
commentId,
|
||||||
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issueId}):`,
|
source: "event",
|
||||||
"",
|
});
|
||||||
promptBody,
|
|
||||||
"",
|
|
||||||
`קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`,
|
|
||||||
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
|
|
||||||
].join("\n"),
|
|
||||||
reason: `user_commented_on_${issue.identifier || issueId}`,
|
|
||||||
});
|
|
||||||
ctx.logger.info("Routed user comment to CEO agent", {
|
|
||||||
issueId,
|
|
||||||
commentId: commentId || null,
|
|
||||||
runId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
ctx.logger.error("Failed to invoke CEO agent for comment routing", {
|
|
||||||
issueId,
|
|
||||||
error: String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Jobs ───────────────────────────────────────────────────────
|
// ── Jobs ───────────────────────────────────────────────────────
|
||||||
@@ -885,6 +992,107 @@ const plugin = definePlugin({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reconciliation sweep — the at-least-once guarantee for user comments.
|
||||||
|
// The event-driven route above is best-effort: a failed/coalesced CEO
|
||||||
|
// invoke, or a comment that lands while an agent is mid-run, can leave a
|
||||||
|
// user comment unrouted with nothing to retry (this is exactly how a
|
||||||
|
// chair comment was silently dropped — see legal-ai task #164). This sweep
|
||||||
|
// re-routes any issue whose newest user comment id does not match the
|
||||||
|
// `last-routed-comment-id` marker, so no comment falls through. Idempotent:
|
||||||
|
// the fast path stamps the marker, so a healthy comment is never
|
||||||
|
// double-routed. Runs every 2 minutes (manifest schedule).
|
||||||
|
ctx.jobs.register("route-pending-comments", async (_job) => {
|
||||||
|
let companies: Awaited<ReturnType<typeof ctx.companies.list>>;
|
||||||
|
try {
|
||||||
|
companies = await ctx.companies.list();
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn("route-pending-comments: companies.list failed", {
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const company of companies) {
|
||||||
|
const ceoAgentId = CEO_AGENT_IDS[company.id];
|
||||||
|
if (!ceoAgentId) continue;
|
||||||
|
|
||||||
|
let issues: Awaited<ReturnType<typeof ctx.issues.list>>;
|
||||||
|
try {
|
||||||
|
issues = await ctx.issues.list({ companyId: company.id, limit: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn("route-pending-comments: issues.list failed", {
|
||||||
|
companyId: company.id,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
// Terminal issues need no routing. A fresh user comment on a
|
||||||
|
// `done` issue reopens it natively → it becomes active and is
|
||||||
|
// caught on the next sweep.
|
||||||
|
if (issue.status === "done" || issue.status === "cancelled") continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const comments = await ctx.issues.listComments(
|
||||||
|
issue.id,
|
||||||
|
company.id,
|
||||||
|
);
|
||||||
|
if (comments.length === 0) continue;
|
||||||
|
|
||||||
|
// "Pending" = the chair spoke last among real participants: the
|
||||||
|
// newest USER comment is newer than the newest AGENT comment (or
|
||||||
|
// no agent has commented yet), so an agent never responded to it.
|
||||||
|
// Order-independent (sort by createdAt, do NOT rely on the array
|
||||||
|
// order of listComments) and system comments are ignored — an
|
||||||
|
// automated "draft ready" line is not an agent response. This
|
||||||
|
// targets the exact failure (chair commented, no agent reply) and
|
||||||
|
// skips historically-answered comments.
|
||||||
|
const ts = (c: { createdAt: string | Date }) =>
|
||||||
|
new Date(c.createdAt).getTime();
|
||||||
|
let newestUser: (typeof comments)[number] | null = null;
|
||||||
|
let newestAgent: (typeof comments)[number] | null = null;
|
||||||
|
for (const c of comments) {
|
||||||
|
if (c.authorType === "user") {
|
||||||
|
if (!newestUser || ts(c) > ts(newestUser)) newestUser = c;
|
||||||
|
} else if (c.authorType === "agent") {
|
||||||
|
if (!newestAgent || ts(c) > ts(newestAgent)) newestAgent = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!newestUser) continue; // no chair comment
|
||||||
|
// An agent has already responded after the chair — handled.
|
||||||
|
if (newestAgent && ts(newestAgent) >= ts(newestUser)) continue;
|
||||||
|
|
||||||
|
const marker = await ctx.state.get({
|
||||||
|
scopeKind: "issue",
|
||||||
|
scopeId: issue.id,
|
||||||
|
stateKey: LAST_ROUTED_COMMENT_KEY,
|
||||||
|
});
|
||||||
|
// Already routed (CEO may still be working) — don't re-fire.
|
||||||
|
if (marker === newestUser.id) continue;
|
||||||
|
|
||||||
|
ctx.logger.info(
|
||||||
|
"route-pending-comments: re-routing unrouted user comment",
|
||||||
|
{ issueId: issue.id, commentId: newestUser.id },
|
||||||
|
);
|
||||||
|
await routeCommentToCeo({
|
||||||
|
issue,
|
||||||
|
companyId: company.id,
|
||||||
|
ceoAgentId,
|
||||||
|
commentBody: newestUser.body,
|
||||||
|
commentId: newestUser.id,
|
||||||
|
source: "sweep",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn("route-pending-comments: failed for issue", {
|
||||||
|
issueId: issue.id,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Data handlers (UI bridge) ──────────────────────────────────
|
// ── Data handlers (UI bridge) ──────────────────────────────────
|
||||||
// These back `usePluginData(key, params)` calls from the React UI bundle
|
// These back `usePluginData(key, params)` calls from the React UI bundle
|
||||||
// in `dist/ui/index.js`. Errors are caught and rethrown with a clear
|
// in `dist/ui/index.js`. Errors are caught and rethrown with a clear
|
||||||
|
|||||||
Reference in New Issue
Block a user