@saadnvd1/agent-os 0.3.497 → 0.3.499
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -5,6 +5,8 @@ import type { ChatItem } from "@/lib/chat/events";
|
|
|
5
5
|
import { cn } from "@/lib/utils";
|
|
6
6
|
import { leadingCommand } from "@/lib/chat/commands";
|
|
7
7
|
import { compactedLine } from "@/lib/chat/stale";
|
|
8
|
+
import { splitInlineCode, splitUserText } from "@/lib/chat/user-text";
|
|
9
|
+
import { CodeBlock } from "./Code";
|
|
8
10
|
import { CopyButton } from "./CopyButton";
|
|
9
11
|
import { ImageThumb } from "./ImageThumb";
|
|
10
12
|
import { Markdown } from "./Markdown";
|
|
@@ -13,13 +15,44 @@ import { useRowState } from "./rowState";
|
|
|
13
15
|
|
|
14
16
|
type Of<K extends ChatItem["kind"]> = Extract<ChatItem, { kind: K }>;
|
|
15
17
|
|
|
18
|
+
function Prose({ text }: { text: string }) {
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
{splitInlineCode(text).map((p, i) =>
|
|
22
|
+
p.code ? (
|
|
23
|
+
<code
|
|
24
|
+
key={i}
|
|
25
|
+
className="bg-foreground/[0.06] rounded px-1 py-0.5 font-mono text-[0.9em]"
|
|
26
|
+
>
|
|
27
|
+
{p.text}
|
|
28
|
+
</code>
|
|
29
|
+
) : (
|
|
30
|
+
<span key={i}>{p.text}</span>
|
|
31
|
+
)
|
|
32
|
+
)}
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
function UserText({ text }: { text: string }) {
|
|
17
|
-
const command = leadingCommand(text);
|
|
18
|
-
if (!command) return <>{text}</>;
|
|
19
38
|
return (
|
|
20
39
|
<>
|
|
21
|
-
|
|
22
|
-
|
|
40
|
+
{splitUserText(text).map((part, i) => {
|
|
41
|
+
if (part.kind === "code")
|
|
42
|
+
return (
|
|
43
|
+
<div key={i} className="my-1.5 first:mt-0 last:mb-0">
|
|
44
|
+
<CodeBlock code={part.code} language={part.language} />
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
const command = i === 0 ? leadingCommand(part.text) : null;
|
|
48
|
+
if (!command) return <Prose key={i} text={part.text} />;
|
|
49
|
+
return (
|
|
50
|
+
<span key={i}>
|
|
51
|
+
<span className="text-primary font-mono">{command}</span>
|
|
52
|
+
<Prose text={part.text.slice(command.length)} />
|
|
53
|
+
</span>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
23
56
|
</>
|
|
24
57
|
);
|
|
25
58
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type UserTextPart =
|
|
2
|
+
| { kind: "text"; text: string }
|
|
3
|
+
| { kind: "code"; code: string; language?: string };
|
|
4
|
+
|
|
5
|
+
const OPEN = /^ {0,3}(`{3,}|~{3,})\s*([\w+-]*)[^\n`]*$/;
|
|
6
|
+
|
|
7
|
+
// A sent message split at its fenced code blocks. Everything else stays the
|
|
8
|
+
// text it was typed as: no emphasis, no HTML, no reflow. An unclosed fence
|
|
9
|
+
// runs to the end, as in markdown.
|
|
10
|
+
export function splitUserText(text: string): UserTextPart[] {
|
|
11
|
+
if (!text.includes("```") && !text.includes("~~~"))
|
|
12
|
+
return [{ kind: "text", text }];
|
|
13
|
+
const parts: UserTextPart[] = [];
|
|
14
|
+
let prose: string[] = [];
|
|
15
|
+
let code: string[] | null = null;
|
|
16
|
+
let fence = "";
|
|
17
|
+
let language: string | undefined;
|
|
18
|
+
const flushProse = () => {
|
|
19
|
+
if (prose.length) parts.push({ kind: "text", text: prose.join("\n") });
|
|
20
|
+
prose = [];
|
|
21
|
+
};
|
|
22
|
+
for (const line of text.replace(/\r\n?/g, "\n").split("\n")) {
|
|
23
|
+
if (code === null) {
|
|
24
|
+
const m = OPEN.exec(line);
|
|
25
|
+
if (m) {
|
|
26
|
+
flushProse();
|
|
27
|
+
code = [];
|
|
28
|
+
fence = m[1];
|
|
29
|
+
language = m[2] || undefined;
|
|
30
|
+
} else prose.push(line);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const t = line.trim();
|
|
34
|
+
if (
|
|
35
|
+
t.startsWith(fence[0]) &&
|
|
36
|
+
t.length >= fence.length &&
|
|
37
|
+
!/[^`~]/.test(t) &&
|
|
38
|
+
t[0] === fence[0]
|
|
39
|
+
) {
|
|
40
|
+
parts.push({ kind: "code", code: code.join("\n"), language });
|
|
41
|
+
code = null;
|
|
42
|
+
} else code.push(line);
|
|
43
|
+
}
|
|
44
|
+
if (code !== null)
|
|
45
|
+
parts.push({ kind: "code", code: code.join("\n"), language });
|
|
46
|
+
flushProse();
|
|
47
|
+
return parts;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Backtick spans inside prose, split out so they can be set in monospace.
|
|
51
|
+
export function splitInlineCode(
|
|
52
|
+
text: string
|
|
53
|
+
): { code: boolean; text: string }[] {
|
|
54
|
+
const out: { code: boolean; text: string }[] = [];
|
|
55
|
+
let last = 0;
|
|
56
|
+
for (const m of text.matchAll(/(`+)(?!`)([^\n]*?[^`\n])\1(?!`)/g)) {
|
|
57
|
+
if (m.index > last)
|
|
58
|
+
out.push({ code: false, text: text.slice(last, m.index) });
|
|
59
|
+
out.push({ code: true, text: m[2] });
|
|
60
|
+
last = m.index + m[0].length;
|
|
61
|
+
}
|
|
62
|
+
if (last < text.length) out.push({ code: false, text: text.slice(last) });
|
|
63
|
+
return out;
|
|
64
|
+
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1507,6 +1507,20 @@ const migrations: Migration[] = [
|
|
|
1507
1507
|
);
|
|
1508
1508
|
},
|
|
1509
1509
|
},
|
|
1510
|
+
{
|
|
1511
|
+
id: 67,
|
|
1512
|
+
name: "orchestrator_event_log_suppressed",
|
|
1513
|
+
up: (db) => {
|
|
1514
|
+
// A capped event is logged as suppressed so a drop leaves a trace.
|
|
1515
|
+
const columns = db
|
|
1516
|
+
.prepare(`PRAGMA table_info(orchestrator_event_log)`)
|
|
1517
|
+
.all() as { name: string }[];
|
|
1518
|
+
if (!columns.some((c) => c.name === "suppressed"))
|
|
1519
|
+
db.exec(
|
|
1520
|
+
`ALTER TABLE orchestrator_event_log ADD COLUMN suppressed TEXT`
|
|
1521
|
+
);
|
|
1522
|
+
},
|
|
1523
|
+
},
|
|
1510
1524
|
];
|
|
1511
1525
|
|
|
1512
1526
|
// `upTo`: stop after this id (tests that start from an older database).
|
|
@@ -276,7 +276,8 @@ async function merge(
|
|
|
276
276
|
return;
|
|
277
277
|
}
|
|
278
278
|
setArmed(task.id, false);
|
|
279
|
-
// The merged condition would say it again on the next diff
|
|
279
|
+
// The merged condition would say it again on the next diff, so this armed
|
|
280
|
+
// line is the one merged notice. deliver.ts never caps `armed:` events.
|
|
280
281
|
markTold(workspaceId, `merged:${task.id}`, task.id);
|
|
281
282
|
event(
|
|
282
283
|
workspaceId,
|
|
@@ -4,10 +4,13 @@ import {
|
|
|
4
4
|
isAnswer,
|
|
5
5
|
isAwayEvent,
|
|
6
6
|
deliveriesSince,
|
|
7
|
+
type EventRow,
|
|
7
8
|
lastDeliveredAt,
|
|
8
9
|
logDeliveries,
|
|
10
|
+
logSuppressed,
|
|
9
11
|
markDelivered,
|
|
10
12
|
pendingEvents,
|
|
13
|
+
suppressedSince,
|
|
11
14
|
unlogDeliveries,
|
|
12
15
|
} from "./events";
|
|
13
16
|
import { isPaused } from "./pause";
|
|
@@ -20,6 +23,25 @@ export const LOW_HOLD_MS = 10 * 60 * 1000;
|
|
|
20
23
|
export const SUBJECT_CAP = 6;
|
|
21
24
|
const HOUR = 60 * 60 * 1000;
|
|
22
25
|
|
|
26
|
+
// Terminal and decision events are never capped: a merge, a gate refusal or
|
|
27
|
+
// failure, an escalation to Saad, a drop or close, a review verdict, an
|
|
28
|
+
// ask's answer. A finished task raises nothing after them, so a dropped one
|
|
29
|
+
// is gone for good. The cap is for repeating noise (CI green, stack steps).
|
|
30
|
+
const UNCAPPED = [
|
|
31
|
+
"merged:",
|
|
32
|
+
"blocked:",
|
|
33
|
+
"stack:merged:",
|
|
34
|
+
"stack:failed:",
|
|
35
|
+
"stack-status:",
|
|
36
|
+
"once:armed:", // merged, refused, failed, disarmed, closed
|
|
37
|
+
"once:review:",
|
|
38
|
+
"once:ask:",
|
|
39
|
+
"once:conflict:",
|
|
40
|
+
"once:plan-done:",
|
|
41
|
+
];
|
|
42
|
+
export const bypassesCap = (e: Pick<EventRow, "key">) =>
|
|
43
|
+
UNCAPPED.some((p) => e.key.startsWith(p));
|
|
44
|
+
|
|
23
45
|
export const AGENTOS_SENDER = "agentos";
|
|
24
46
|
|
|
25
47
|
export type ChatSender = (
|
|
@@ -47,7 +69,8 @@ const failing = new Map<string, { failures: number; until: number }>();
|
|
|
47
69
|
// Rows are marked delivered before the send so a restart can't send them
|
|
48
70
|
// twice. The cost: if the process dies between marking and the send
|
|
49
71
|
// reaching the worker, those events are lost rather than repeated. That's
|
|
50
|
-
// the cheaper failure for a paid turn
|
|
72
|
+
// the cheaper failure for a paid turn. (A condition that still holds is
|
|
73
|
+
// re-raised by the next change; a one-off event is not.)
|
|
51
74
|
export async function deliverEvents(opts: {
|
|
52
75
|
workspaceId: string;
|
|
53
76
|
orchestratorId: string;
|
|
@@ -62,22 +85,33 @@ export async function deliverEvents(opts: {
|
|
|
62
85
|
if ((failing.get(workspaceId)?.until ?? 0) > now) return null;
|
|
63
86
|
if (now - lastDeliveredAt(workspaceId) < BATCH_WINDOW_MS) return null;
|
|
64
87
|
|
|
65
|
-
// Over a subject's hourly cap: dropped, not sent later
|
|
88
|
+
// Over a subject's hourly cap: dropped, not sent later, but logged as
|
|
89
|
+
// suppressed and counted into the subject's next line. Terminal events
|
|
90
|
+
// skip the cap.
|
|
66
91
|
const counts = new Map<string, number>();
|
|
67
|
-
const capped:
|
|
92
|
+
const capped: EventRow[] = [];
|
|
68
93
|
const pending = pendingEvents(workspaceId).filter((e) => {
|
|
69
|
-
if (!e.subject) return true;
|
|
94
|
+
if (!e.subject || bypassesCap(e)) return true;
|
|
70
95
|
const n =
|
|
71
96
|
counts.get(e.subject) ??
|
|
72
97
|
deliveriesSince(workspaceId, e.subject, now - HOUR);
|
|
73
98
|
if (n >= SUBJECT_CAP) {
|
|
74
|
-
capped.push(e
|
|
99
|
+
capped.push(e);
|
|
75
100
|
return false;
|
|
76
101
|
}
|
|
77
102
|
counts.set(e.subject, n + 1);
|
|
78
103
|
return true;
|
|
79
104
|
});
|
|
80
|
-
if (capped.length)
|
|
105
|
+
if (capped.length) {
|
|
106
|
+
markDelivered(
|
|
107
|
+
capped.map((e) => e.id),
|
|
108
|
+
now
|
|
109
|
+
);
|
|
110
|
+
logSuppressed(workspaceId, capped, now);
|
|
111
|
+
console.warn(
|
|
112
|
+
`[orchestrator] ${capped.length} event(s) over the hourly cap dropped: ${capped.map((e) => e.key).join(", ")}`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
81
115
|
if (!pending.length) return null;
|
|
82
116
|
const oldestLow = Math.min(
|
|
83
117
|
...pending.filter((e) => e.low).map((e) => Date.parse(e.created_at))
|
|
@@ -85,7 +119,15 @@ export async function deliverEvents(opts: {
|
|
|
85
119
|
if (pending.every((e) => e.low) && now - oldestLow < LOW_HOLD_MS) return null;
|
|
86
120
|
|
|
87
121
|
const ids = pending.map((e) => e.id);
|
|
88
|
-
const
|
|
122
|
+
const lines = [...new Set(pending.map((e) => e.line))];
|
|
123
|
+
for (const subject of new Set(pending.map((e) => e.subject))) {
|
|
124
|
+
const n = subject ? suppressedSince(workspaceId, subject, now - HOUR) : 0;
|
|
125
|
+
if (n)
|
|
126
|
+
lines.push(
|
|
127
|
+
`(+${n} capped event${n === 1 ? "" : "s"} for ${subject} in the last hour)`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const text = lines.join("\n");
|
|
89
131
|
// Saad's answers to asks, shown in chat as his decisions.
|
|
90
132
|
const decided = [...new Set(pending.filter(isAnswer).map((e) => e.line))];
|
|
91
133
|
const origin: ChatOrigin = {
|
|
@@ -238,6 +238,38 @@ export function logDeliveries(
|
|
|
238
238
|
)();
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
// Events held back by the subject cap: logged with the line, never counted
|
|
242
|
+
// as deliveries.
|
|
243
|
+
export function logSuppressed(
|
|
244
|
+
workspaceId: string,
|
|
245
|
+
events: Pick<EventRow, "subject" | "key">[],
|
|
246
|
+
at: number
|
|
247
|
+
): void {
|
|
248
|
+
const stmt = db.prepare(
|
|
249
|
+
`INSERT INTO orchestrator_event_log (workspace_id, subject, delivered_at, suppressed) VALUES (?, ?, ?, ?)`
|
|
250
|
+
);
|
|
251
|
+
db.transaction(() => {
|
|
252
|
+
for (const e of events) stmt.run(workspaceId, e.subject, iso(at), e.key);
|
|
253
|
+
})();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Events suppressed for a subject in the window.
|
|
257
|
+
export function suppressedSince(
|
|
258
|
+
workspaceId: string,
|
|
259
|
+
subject: string,
|
|
260
|
+
since: number
|
|
261
|
+
): number {
|
|
262
|
+
return (
|
|
263
|
+
db
|
|
264
|
+
.prepare(
|
|
265
|
+
`SELECT COUNT(*) AS n FROM orchestrator_event_log
|
|
266
|
+
WHERE workspace_id = ? AND subject = ? AND delivered_at >= ?
|
|
267
|
+
AND suppressed IS NOT NULL`
|
|
268
|
+
)
|
|
269
|
+
.get(workspaceId, subject, iso(since)) as { n: number }
|
|
270
|
+
).n;
|
|
271
|
+
}
|
|
272
|
+
|
|
241
273
|
export function unlogDeliveries(ids: number[]): void {
|
|
242
274
|
const stmt = db.prepare(`DELETE FROM orchestrator_event_log WHERE id = ?`);
|
|
243
275
|
db.transaction(() => ids.forEach((id) => stmt.run(id)))();
|
|
@@ -246,7 +278,7 @@ export function unlogDeliveries(ids: number[]): void {
|
|
|
246
278
|
export function lastDeliveredAt(workspaceId: string): number {
|
|
247
279
|
const row = db
|
|
248
280
|
.prepare(
|
|
249
|
-
`SELECT MAX(delivered_at) AS at FROM orchestrator_event_log WHERE workspace_id =
|
|
281
|
+
`SELECT MAX(delivered_at) AS at FROM orchestrator_event_log WHERE workspace_id = ? AND suppressed IS NULL`
|
|
250
282
|
)
|
|
251
283
|
.get(workspaceId) as { at: string | null };
|
|
252
284
|
return row.at ? Date.parse(row.at) : 0;
|
|
@@ -261,7 +293,8 @@ export function deliveriesSince(
|
|
|
261
293
|
db
|
|
262
294
|
.prepare(
|
|
263
295
|
`SELECT COUNT(*) AS n FROM orchestrator_event_log
|
|
264
|
-
WHERE workspace_id = ? AND subject = ? AND delivered_at >=
|
|
296
|
+
WHERE workspace_id = ? AND subject = ? AND delivered_at >= ?
|
|
297
|
+
AND suppressed IS NULL`
|
|
265
298
|
)
|
|
266
299
|
.get(workspaceId, subject, iso(since)) as { n: number }
|
|
267
300
|
).n;
|