Compare commits
6 Commits
0bf2e46212
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d0669a0878 | |||
| b9906f6047 | |||
| 8d933386d3 | |||
| 0b6117cd7e | |||
| fc5d8725f1 | |||
| b9a5bb6e80 |
@@ -148,6 +148,20 @@ export class LegalApi {
|
||||
// Return static style guide reference
|
||||
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
|
||||
@@ -260,3 +274,21 @@ export interface QAResponse {
|
||||
}>;
|
||||
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",
|
||||
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: [
|
||||
{
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
ctx.events.on("issue.created", async (event) => {
|
||||
@@ -697,7 +731,16 @@ const plugin = definePlugin({
|
||||
const matched = commentId
|
||||
? comments.find((c) => c.id === commentId)
|
||||
: 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 =
|
||||
matched?.body ||
|
||||
latest?.body ||
|
||||
@@ -997,14 +1040,28 @@ const plugin = definePlugin({
|
||||
);
|
||||
if (comments.length === 0) continue;
|
||||
|
||||
// "Pending" = the conversation's last word is the chair's, with
|
||||
// no agent reply after it. listComments is chronological (newest
|
||||
// last). If an agent already responded, the comment was handled —
|
||||
// skip. This both targets the exact failure (chair commented, no
|
||||
// agent response) and avoids re-routing historically-answered
|
||||
// comments on first run after deploy.
|
||||
const last = comments[comments.length - 1];
|
||||
if (last.authorType !== "user") 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",
|
||||
@@ -1012,18 +1069,18 @@ const plugin = definePlugin({
|
||||
stateKey: LAST_ROUTED_COMMENT_KEY,
|
||||
});
|
||||
// Already routed (CEO may still be working) — don't re-fire.
|
||||
if (marker === last.id) continue;
|
||||
if (marker === newestUser.id) continue;
|
||||
|
||||
ctx.logger.info(
|
||||
"route-pending-comments: re-routing unrouted user comment",
|
||||
{ issueId: issue.id, commentId: last.id },
|
||||
{ issueId: issue.id, commentId: newestUser.id },
|
||||
);
|
||||
await routeCommentToCeo({
|
||||
issue,
|
||||
companyId: company.id,
|
||||
ceoAgentId,
|
||||
commentBody: last.body,
|
||||
commentId: last.id,
|
||||
commentBody: newestUser.body,
|
||||
commentId: newestUser.id,
|
||||
source: "sweep",
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user