eve-hub 0.5.1-pre.1 → 0.5.2
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.
- package/bot-model.d.mts +17 -0
- package/bot-model.mjs +13 -1
- package/package.json +1 -1
- package/registry-fetch.mjs +3 -1
- package/schedules/tracks-lead.mjs +193 -58
- package/schedules/tracks.js +8 -6
- package/subagent-cap.d.mts +10 -0
- package/templates/main-instructions.md +1 -1
- package/templates/workspace-protocol.md +1 -1
- package/tools/list_agents.js +18 -3
package/bot-model.d.mts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CapAgent, Clamped, SubagentCap } from './subagent-cap.mjs';
|
|
2
|
+
export type RosterAgent = CapAgent & { model: string };
|
|
3
|
+
export type Selection = { model: string; reasoning?: string };
|
|
4
|
+
export type SubagentModels = {
|
|
5
|
+
subagentCap: string;
|
|
6
|
+
subagents: Array<{ name: string; model: string; clamped?: string[] }>;
|
|
7
|
+
agentCopies: { model: string; clamped?: string[] };
|
|
8
|
+
};
|
|
9
|
+
export const CONTEXT_WINDOW: number;
|
|
10
|
+
export function parseModel(label?: string): Selection;
|
|
11
|
+
export function formatModel(selection: Selection): string;
|
|
12
|
+
export function ownModel(agents: readonly RosterAgent[], name: string): Selection;
|
|
13
|
+
export function copyModel(agents: readonly RosterAgent[], cap: SubagentCap, name: string): Clamped;
|
|
14
|
+
export function subagentModels(agents: readonly RosterAgent[], cap: SubagentCap, name: string): SubagentModels;
|
|
15
|
+
export function isAgentCopy(ctx: unknown): boolean;
|
|
16
|
+
export function modelOptionsFor(model: string): { providerOptions: Record<string, unknown> } | undefined;
|
|
17
|
+
export function selectModel(agents: readonly RosterAgent[], cap: SubagentCap, name: string, ctx: unknown): Selection & { modelContextWindowTokens: number; modelOptions?: unknown; copy: boolean };
|
package/bot-model.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Built-in `agent` copies of a bot (eve's root-copy tool) run under the same
|
|
7
7
|
// subagent cap as declared subagents: the bot's own model and reasoning,
|
|
8
8
|
// clamped (never rejected) to the cap.
|
|
9
|
-
import { capFor, clampSubagent } from "./subagent-cap.mjs";
|
|
9
|
+
import { capFor, clampSubagent, resolveSubagents } from "./subagent-cap.mjs";
|
|
10
10
|
|
|
11
11
|
export const CONTEXT_WINDOW = 200_000;
|
|
12
12
|
|
|
@@ -31,6 +31,18 @@ export function copyModel(agents, cap, name) {
|
|
|
31
31
|
return clampSubagent(ownModel(agents, name), capFor(agents, cap, name));
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// What list_agents reports `name`'s cap, declared subagents and `agent` copies
|
|
35
|
+
// run on, on the local and hosted hubs alike.
|
|
36
|
+
export function subagentModels(agents, cap, name) {
|
|
37
|
+
const notes = ({ clamped }) => (clamped.length ? { clamped } : {});
|
|
38
|
+
const copy = copyModel(agents, cap, name);
|
|
39
|
+
return {
|
|
40
|
+
subagentCap: formatModel(capFor(agents, cap, name)),
|
|
41
|
+
subagents: resolveSubagents(agents, cap, name).map((s) => ({ name: s.name, model: formatModel(s), ...notes(s) })),
|
|
42
|
+
agentCopies: { model: formatModel(copy), ...notes(copy) },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
// eve runs a root copy as a session on the "subagent" channel; a bot's own
|
|
35
47
|
// sessions come in over its eve/http channel. (ctx.session.parent is not
|
|
36
48
|
// populated in dynamic-model resolvers, so the channel is the signal.)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eve-hub",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
|
|
6
6
|
"type": "module",
|
package/registry-fetch.mjs
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
// (plain node) import the same module.
|
|
19
19
|
|
|
20
20
|
// Read per call: a test (or the daemon) can point it at another registry.
|
|
21
|
-
|
|
21
|
+
// Without its trailing slash: every path starts with one, and `//api/...`
|
|
22
|
+
// is a different route (a 404 on the hosted hub).
|
|
23
|
+
export const registryBase = () => (process.env.ROSTER_REGISTRY_URL ?? "http://127.0.0.1:3100").replace(/\/+$/, "");
|
|
22
24
|
|
|
23
25
|
// Also used by the sandbox's node:http transport, which distinguishes
|
|
24
26
|
// requests that never connected from ones whose result may have been lost.
|
|
@@ -1,91 +1,226 @@
|
|
|
1
1
|
// main's tracks schedule (schedules/tracks.ts): main leads every track (a
|
|
2
|
-
// path below its project, hub/native; registry: snap.tracks keyed by path
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// of them has a new message since the last pass does it post one notice in
|
|
7
|
-
// sch-main-tracks tagging main, listing the tracks that moved. What the
|
|
8
|
-
// summary says, and whether it changes at all, is main's call.
|
|
2
|
+
// path below its project, hub/native; registry: snap.tracks keyed by path)
|
|
3
|
+
// by keeping its free-form summary current with set_summary. A main turn is
|
|
4
|
+
// expensive, so every 10 minutes this decides, per thread, whether there is
|
|
5
|
+
// anything to summarize before waking main:
|
|
9
6
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
7
|
+
// facts code only gathers facts, from the one snapshot read: the latest
|
|
8
|
+
// human message, the bot messages since it, the latest bot
|
|
9
|
+
// message, the latest bot event of any kind (registry session
|
|
10
|
+
// lastEventAt, nested subagent runs included), who's working, and
|
|
11
|
+
// when the thread's track (or the thread) was last summarized.
|
|
12
|
+
// ask a thread is asked about when something happened since that
|
|
13
|
+
// summary and its basis (newest message, summary time, who's
|
|
14
|
+
// working) differs from the one it was last asked on. Each
|
|
15
|
+
// goes to the model (DECISION_MODEL) in its own evaluation, all
|
|
16
|
+
// in parallel, with its track's summary and the messages since
|
|
17
|
+
// it: does the summary already state them (missing, stated,
|
|
18
|
+
// chatter), and what changed. No thresholds in code. Each verdict is kept against its basis (state.seen):
|
|
19
|
+
// the model is close to even on borderline threads, so asking
|
|
20
|
+
// again on the same facts would wake main by chance, not news.
|
|
21
|
+
// wake only threads whose verdict is `missing` reach main, in one post
|
|
22
|
+
// in sch-main-tracks with the answers and facts.
|
|
23
|
+
//
|
|
24
|
+
// A failed or invalid evaluation posts and saves nothing: the next tick asks
|
|
25
|
+
// again. Without `evaluate` (tests) nothing is asked and nobody is woken.
|
|
13
26
|
//
|
|
14
27
|
// Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
|
|
15
28
|
// tests (plain node) import the same module.
|
|
16
29
|
import { runOnce } from "./hub.mjs";
|
|
30
|
+
import { DECISION_MODEL } from "./dash-classify.mjs";
|
|
31
|
+
import { clip, flat, said } from "./thread-brief.mjs";
|
|
17
32
|
|
|
18
|
-
// What counts as a new message: something a bot or the human said, not a
|
|
19
|
-
// hub event (join/leave, schedule notes) or a system post.
|
|
20
|
-
const said = (m) => !!m && m.authorKind !== "event" && !m.event && m.author !== "system";
|
|
21
33
|
// A track chat is main's own conversation with the human; it doesn't move the track.
|
|
22
34
|
const counted = (t) => !!t && t.kind === "thread" && !t.chatFor;
|
|
23
35
|
const pathOf = (t) => t.tags?.path ?? t.trackId ?? null;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
const newest = (...ats) => ats.filter(Boolean).sort().at(-1) ?? null;
|
|
37
|
+
const isHuman = (m, humans) => m.authorKind === "human" || humans.has(m.author);
|
|
38
|
+
|
|
39
|
+
// The messages since the summary the model reads, newest last: news (a
|
|
40
|
+
// merge) is often followed by acks, so the latest message alone misses it.
|
|
41
|
+
export const RECENT = 6;
|
|
42
|
+
|
|
43
|
+
// The facts for one thread on an open track.
|
|
44
|
+
export function threadFacts(t, snap, track) {
|
|
45
|
+
const humans = new Set((snap?.humans ?? []).map((h) => h.name));
|
|
46
|
+
const ms = (t.messages ?? []).filter(said);
|
|
47
|
+
let h = ms.length - 1;
|
|
48
|
+
while (h >= 0 && !isHuman(ms[h], humans)) h--;
|
|
49
|
+
const human = h >= 0 ? ms[h] : null;
|
|
50
|
+
const bot = ms.findLast((m) => !isHuman(m, humans)) ?? null;
|
|
51
|
+
const sessions = Object.values(snap?.sessions ?? {}).filter((s) => s?.threadId === t.id);
|
|
52
|
+
const summaryAt = newest(track.summary?.at, t.summary?.at);
|
|
53
|
+
const since = ms.filter((m) => !summaryAt || m.at > summaryAt);
|
|
54
|
+
return {
|
|
55
|
+
id: t.id,
|
|
56
|
+
title: t.title ?? t.id,
|
|
57
|
+
track: track.id,
|
|
58
|
+
trackTitle: track.title ?? track.id,
|
|
59
|
+
archived: !!t.archived,
|
|
60
|
+
phase: t.work?.phase ?? null,
|
|
61
|
+
working: Object.entries(t.status ?? {}).filter(([, s]) => s === "working").map(([b]) => b).sort(),
|
|
62
|
+
human: human && { id: human.id, author: human.author, at: human.at, text: human.text },
|
|
63
|
+
botsSinceHuman: ms.length - 1 - h,
|
|
64
|
+
bot: bot && { id: bot.id, author: bot.author, at: bot.at, text: bot.text },
|
|
65
|
+
lastId: ms.at(-1)?.id ?? null,
|
|
66
|
+
lastAt: ms.at(-1)?.at ?? null,
|
|
67
|
+
eventAt: newest(...sessions.map((s) => s.lastEventAt), ...Object.values(t.busySince ?? {})),
|
|
68
|
+
summaryAt,
|
|
69
|
+
sinceSummary: since.length,
|
|
70
|
+
recent: since.slice(-RECENT).map((m) => ({ id: m.id, author: m.author, at: m.at, text: m.text })),
|
|
71
|
+
summary: track.summary?.text ? { text: track.summary.text, by: track.summary.by ?? null } : null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// What a verdict was made on: a thread is asked again once this differs.
|
|
76
|
+
// The bots' event time isn't in it: it moves every tick while a bot works.
|
|
77
|
+
export const basisOf = (f) => [f.lastId, f.summaryAt ?? "", f.working.join(",")].join("|");
|
|
27
78
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
// message id, from before paths moved, reads as "on its current path").
|
|
33
|
-
export function tracksMoved(snap, seen = {}) {
|
|
79
|
+
// Every filed thread on an open track, as facts; and of those, the ones to
|
|
80
|
+
// ask about: activity since the last summary, on a basis not yet asked
|
|
81
|
+
// about (seen: { [threadId]: basis }).
|
|
82
|
+
export function tracksFacts(snap, seen = {}) {
|
|
34
83
|
const tracks = snap?.tracks ?? {};
|
|
35
|
-
const
|
|
36
|
-
const byTrack = new Map();
|
|
84
|
+
const all = [];
|
|
37
85
|
for (const t of Object.values(snap?.threads ?? {})) {
|
|
38
86
|
if (!counted(t)) continue;
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
const ms = (t.messages ?? []).filter(said);
|
|
42
|
-
const last = ms.at(-1);
|
|
43
|
-
const on = tracksOn(p, tracks);
|
|
44
|
-
if (on.length && last) next[t.id] = { m: last.id, p };
|
|
45
|
-
const fresh = !last || was?.m === last.id ? 0 : was?.m ? Math.max(0, ms.length - 1 - ms.findIndex((m) => m.id === was.m)) : ms.length;
|
|
46
|
-
const refiled = !!was && was.p !== p;
|
|
47
|
-
if (!fresh && !refiled) continue;
|
|
48
|
-
const affected = new Set([...on, ...(refiled ? tracksOn(was.p, tracks) : [])]);
|
|
49
|
-
if (!affected.size) continue;
|
|
50
|
-
const entry = { id: t.id, title: t.title ?? t.id, archived: !!t.archived, fresh, lastBy: last?.author ?? null, lastAt: last?.at ?? null, ...(refiled ? { from: was.p ?? null, to: p } : {}) };
|
|
51
|
-
for (const k of affected) {
|
|
52
|
-
if (!byTrack.has(k)) byTrack.set(k, []);
|
|
53
|
-
byTrack.get(k).push(entry);
|
|
54
|
-
}
|
|
87
|
+
const track = tracks[pathOf(t)];
|
|
88
|
+
if (track && track.open !== false) all.push(threadFacts(t, snap, track));
|
|
55
89
|
}
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
90
|
+
const since = (f) => !f.summaryAt || [f.lastAt, f.eventAt].some((at) => at && at > f.summaryAt);
|
|
91
|
+
const ask = all.filter((f) => f.lastId && since(f) && seen[f.id] !== basisOf(f));
|
|
92
|
+
ask.sort((a, b) => a.track.localeCompare(b.track) || String(b.lastAt).localeCompare(String(a.lastAt)));
|
|
93
|
+
return { all, ask };
|
|
59
94
|
}
|
|
60
95
|
|
|
61
96
|
const ago = (at, now) => {
|
|
97
|
+
if (!at) return "never";
|
|
62
98
|
const min = Math.max(0, Math.round((now - Date.parse(at)) / 60_000));
|
|
63
99
|
return min < 60 ? `${min}m ago` : min < 48 * 60 ? `${Math.round(min / 60)}h ago` : `${Math.round(min / 1440)}d ago`;
|
|
64
100
|
};
|
|
65
101
|
|
|
66
|
-
export
|
|
102
|
+
export const FACT_CHARS = { summary: 2000, human: 400, bot: 600, recent: 500 };
|
|
103
|
+
|
|
104
|
+
// One track's summary as the model reads it, once for all its threads.
|
|
105
|
+
export function trackText(f, now) {
|
|
106
|
+
return `## Track ${f.track} ("${f.trackTitle}")\nTrack summary: ${f.summary ? `written ${ago(f.summaryAt, now)} by ${f.summary.by ?? "?"}:\n${clip(String(f.summary.text).trim(), FACT_CHARS.summary)}` : "none yet"}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// One thread's facts as the model reads them.
|
|
110
|
+
export function factsText(f, now) {
|
|
111
|
+
const msg = (m, n) => (m ? `${m.author}, ${ago(m.at, now)}: ${clip(flat(m.text), n)}` : "none");
|
|
112
|
+
return [
|
|
113
|
+
`### Thread ${f.id}: "${f.title}"${f.archived ? " (archived)" : ""}`,
|
|
114
|
+
`Latest human message: ${msg(f.human, FACT_CHARS.human)}`,
|
|
115
|
+
`Bot messages since the latest human message: ${f.botsSinceHuman}`,
|
|
116
|
+
`Latest bot message: ${msg(f.bot, FACT_CHARS.bot)}`,
|
|
117
|
+
`Messages since the track summary was written: ${f.sinceSummary}${f.sinceSummary > f.recent.length ? ` (the last ${f.recent.length} below)` : ""}`,
|
|
118
|
+
...f.recent.map((m) => `- ${msg(m, FACT_CHARS.recent)}`),
|
|
119
|
+
`Latest bot activity of any kind (tool calls, subagents included): ${ago(f.eventAt, now)}`,
|
|
120
|
+
`Bots working now: ${f.working.join(", ") || "none"}${f.phase ? ` · phase the bots reported: ${f.phase}` : ""}`,
|
|
121
|
+
].join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The verdict: one factual question, does the track summary already state
|
|
125
|
+
// what this thread's latest messages say? Only `missing` wakes main.
|
|
126
|
+
export const VERDICT = {
|
|
127
|
+
missing: "The thread's latest messages state where the work stands (a decision, a plan, a PR opened, pushed, reviewed or merged, work finished, a problem, a question for the human) and the track summary doesn't state it, or states an older status (e.g. the summary calls a PR a draft or awaiting review and the thread says it merged). Also when the track has no summary yet.",
|
|
128
|
+
stated: "The track summary already states it, including the current status of every PR and decision the messages mention. A summary that names the PR but gives an older status doesn't count.",
|
|
129
|
+
chatter: "The messages don't state anything about where the work stands: acks, progress narration, a bot handing off to another bot mid-task with no outcome yet.",
|
|
130
|
+
};
|
|
131
|
+
export const wakes = (d) => d.verdict === "missing";
|
|
132
|
+
export const CHANGE = {
|
|
133
|
+
nothing: "Nothing that matters changed.",
|
|
134
|
+
progress: "Work moved forward; nothing settled yet.",
|
|
135
|
+
plan: "A plan or approach was posted or changed.",
|
|
136
|
+
needs_human: "A bot now waits on the human: a question, a decision or a sign-off.",
|
|
137
|
+
decided: "The human decided, approved or redirected something.",
|
|
138
|
+
pr: "A PR was opened, pushed, reviewed or its CI changed.",
|
|
139
|
+
landed: "Work was merged, shipped or finished.",
|
|
140
|
+
problem: "Something broke, failed or got stuck.",
|
|
141
|
+
};
|
|
142
|
+
// The evaluation for one thread: its track's summary, then its facts.
|
|
143
|
+
// -> { state, questions }
|
|
144
|
+
export function threadQuestions(f, now) {
|
|
145
|
+
const questions = {
|
|
146
|
+
verdict: { type: "choice", instructions: "Does the track summary already state what this thread's messages since it say, with each PR's and decision's current status?", criteria: VERDICT },
|
|
147
|
+
change: { type: "choice", instructions: "What changed in this thread since the track summary was written?", criteria: CHANGE },
|
|
148
|
+
};
|
|
149
|
+
const state = [
|
|
150
|
+
"A chat thread where AI bots work for a human. It sits on a track (a feature); main keeps a short summary per track so the human sees what's up without reading the threads. Compare the thread's messages with the track summary's text: answer from what the summary says, not from how long ago it was written. Times are relative to now.",
|
|
151
|
+
trackText(f, now),
|
|
152
|
+
factsText(f, now),
|
|
153
|
+
].join("\n\n");
|
|
154
|
+
return { state, questions };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Ask about each thread in `ask`, one evaluation per thread, all at once:
|
|
158
|
+
// given every thread in one evaluation, the model answered for the wrong
|
|
159
|
+
// thread (merges in a thread's latest message read as already stated). -> { decided: [{ ...facts,
|
|
160
|
+
// verdict, change }], usage, ms }, or null when a call failed or an answer
|
|
161
|
+
// is invalid.
|
|
162
|
+
export async function decideTracks(ask, { evaluate, model = DECISION_MODEL, now = Date.now(), timeoutMs = 30_000, log = console }) {
|
|
163
|
+
const started = Date.now();
|
|
164
|
+
let rs;
|
|
165
|
+
try {
|
|
166
|
+
rs = await Promise.all(ask.map((f) => evaluate({ model, ...threadQuestions(f, now), maxRetries: 1, abortSignal: AbortSignal.timeout(timeoutMs) })));
|
|
167
|
+
} catch (err) {
|
|
168
|
+
log?.warn?.(`tracks: evaluation failed, tick skipped: ${err?.message ?? err}`);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const ms = Date.now() - started;
|
|
172
|
+
const pick = (r, q, criteria) => {
|
|
173
|
+
const a = r?.answers?.[q];
|
|
174
|
+
return a?.type === "choice" && Object.hasOwn(criteria, a.choice) ? a.choice : null;
|
|
175
|
+
};
|
|
176
|
+
const decided = ask.map((f, i) => ({ ...f, verdict: pick(rs[i], "verdict", VERDICT), change: pick(rs[i], "change", CHANGE) }));
|
|
177
|
+
if (decided.some((d) => !d.verdict || !d.change)) {
|
|
178
|
+
log?.warn?.("tracks: invalid answer, tick skipped");
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const usage = { inputTokens: rs.reduce((n, r) => n + (r?.usage?.inputTokens ?? 0), 0) };
|
|
182
|
+
log?.log?.(`tracks: asked about ${ask.length} threads in ${ms} ms (${usage.inputTokens} input tokens), ${decided.filter(wakes).length} need an update`);
|
|
183
|
+
return { decided, usage, ms };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The post that wakes main: only the threads that need an update, by track,
|
|
187
|
+
// each with the model's answers and its facts.
|
|
188
|
+
export function tracksNotice(decided, now = Date.now()) {
|
|
67
189
|
const lines = [
|
|
68
|
-
"@main These
|
|
69
|
-
"",
|
|
190
|
+
"@main These threads need their track summary updated; the tracks classifier skipped the rest. Keep each track's summary current with `set_summary` (scope `path:<path>`), a short markdown note of what's up, written for the human. Read the thread (`read_thread`) where the facts below aren't enough. Reply here with one line; mention nobody.",
|
|
70
191
|
];
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
lines.push(` - [${t.id}](${t.id}) ${t.title}: ${t.fresh} new${t.lastBy ? `, latest by ${t.lastBy}` : ""}${filed}${t.archived ? " (archived)" : ""}`);
|
|
192
|
+
let track = null;
|
|
193
|
+
for (const d of decided) {
|
|
194
|
+
if (d.track !== track) {
|
|
195
|
+
track = d.track;
|
|
196
|
+
lines.push("", `- **${d.trackTitle}** (\`${d.track}\`): ${d.summary ? `summary written ${ago(d.summaryAt, now)}` : "no summary yet"}`);
|
|
77
197
|
}
|
|
198
|
+
const human = d.human ? `human ${ago(d.human.at, now)}, ${d.botsSinceHuman} bot messages since` : `no human message, ${d.botsSinceHuman} bot messages`;
|
|
199
|
+
lines.push(` - [${d.id}](${d.id}) ${d.title}: ${d.verdict}, changed: ${d.change}; ${human}; latest message ${ago(d.lastAt, now)}${d.working.length ? `; ${d.working.join(", ")} working` : ""}${d.archived ? " (archived)" : ""}`);
|
|
78
200
|
}
|
|
79
201
|
return lines.join("\n");
|
|
80
202
|
}
|
|
81
203
|
|
|
82
|
-
// One
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
204
|
+
// One tick. State: { seen: { [threadId]: basis of its last verdict } }, kept
|
|
205
|
+
// only for threads still on an open track.
|
|
206
|
+
// -> { posts, state, decided, usage, ms }
|
|
207
|
+
/** @param {any} hub @param {{ evaluate?: (options: any) => Promise<any>, now?: number, log?: any, model?: string }} [opts] */
|
|
208
|
+
export async function runTracks(hub, { evaluate, now = Date.now(), log = console, model } = {}) {
|
|
209
|
+
let tick = { decided: [], usage: null, ms: 0 };
|
|
210
|
+
const result = await runOnce(hub, "tracks", async (snap, saved) => {
|
|
86
211
|
const prev = saved?.seen ?? {};
|
|
87
|
-
const {
|
|
212
|
+
const { all, ask } = tracksFacts(snap, prev);
|
|
213
|
+
if (ask.length && evaluate) {
|
|
214
|
+
const out = await decideTracks(ask, { evaluate, model, now, log });
|
|
215
|
+
if (!out) return { posts: [], state: saved, changed: false };
|
|
216
|
+
tick = out;
|
|
217
|
+
}
|
|
218
|
+
const live = new Set(all.map((f) => f.id));
|
|
219
|
+
const seen = Object.fromEntries(Object.entries(prev).filter(([id]) => live.has(id)));
|
|
220
|
+
for (const d of tick.decided) seen[d.id] = basisOf(d);
|
|
221
|
+
const wake = tick.decided.filter(wakes);
|
|
88
222
|
const changed = JSON.stringify(Object.entries(seen).sort()) !== JSON.stringify(Object.entries(prev).sort());
|
|
89
|
-
return { posts:
|
|
223
|
+
return { posts: wake.length ? [tracksNotice(wake, now)] : [], state: { seen }, changed };
|
|
90
224
|
}, { now });
|
|
225
|
+
return { ...result, ...tick };
|
|
91
226
|
}
|
package/schedules/tracks.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
// main's tracks schedule: every 10 minutes,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// main's tracks schedule: every 10 minutes, gathers facts on the threads filed
|
|
2
|
+
// in open tracks and asks openai/gpt-6-luna-decisions, one `evaluate` per
|
|
3
|
+
// thread (eve's), which of them need their track summary updated.
|
|
4
|
+
// Only those tag main in sch-main-tracks, with the model's answers; main then
|
|
5
|
+
// rewrites the summaries with set_summary. The pass: runTracks in
|
|
6
|
+
// ./tracks-lead.mjs.
|
|
6
7
|
import { defineSchedule } from "eve/schedules";
|
|
8
|
+
import { evaluate } from "eve/ai";
|
|
7
9
|
import { createHub } from "./hub.mjs";
|
|
8
10
|
import { runTracks } from "./tracks-lead.mjs";
|
|
9
11
|
export default defineSchedule({
|
|
10
12
|
cron: "*/10 * * * *",
|
|
11
13
|
async run() {
|
|
12
|
-
await runTracks(createHub());
|
|
14
|
+
await runTracks(createHub(), { evaluate });
|
|
13
15
|
},
|
|
14
16
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type Requested = { model?: string; reasoning?: string };
|
|
2
|
+
export type SubagentCap = Requested & { models?: readonly string[] };
|
|
3
|
+
export type CapAgent = { name: string; subagents?: Record<string, Requested>; subagentCap?: SubagentCap };
|
|
4
|
+
export type Clamped = { model: string; reasoning: string; clamped: string[] };
|
|
5
|
+
export const REASONING_LEVELS: readonly string[];
|
|
6
|
+
export const DEFAULT_SUBAGENT_CAP: Readonly<{ model: string; reasoning: string; models: readonly string[] }>;
|
|
7
|
+
export function clampSubagent(requested?: Requested, cap?: SubagentCap): Clamped;
|
|
8
|
+
export function capFor(agents: readonly CapAgent[], workspaceCap: SubagentCap, botName: string): SubagentCap;
|
|
9
|
+
export function resolveSubagent(agents: readonly CapAgent[], workspaceCap: SubagentCap, botName: string, name: string): Clamped;
|
|
10
|
+
export function resolveSubagents(agents: readonly CapAgent[], workspaceCap: SubagentCap, botName: string): Array<Clamped & { name: string }>;
|
|
@@ -14,7 +14,7 @@ You don't know in advance which bots exist or what they're good at, and the team
|
|
|
14
14
|
## Opening work
|
|
15
15
|
**Paths.** A thread is one piece of work: one task, usually one PR. Every thread sits on one **path**, `project/track`, e.g. `hub/native/composer`: the first segment is the project, the rest is the feature or effort inside it ("world-hub", "AppKit feed port"), which spans many threads and PRs. Put each thread you open on the path for its area if one exists, even read broadly (`list_threads` shows each thread's path; a bug or follow-up in an area belongs to it). A new path only for work that clearly starts a new effort, named for the feature, never the task. Near-duplicates (`hub/tracks` and `hub/tracks-ui`) get merged with `move_path`. Never move a thread whose path the human set (it's locked).
|
|
16
16
|
|
|
17
|
-
**Summaries.** You lead every track: keep each one's summary current with `set_summary` (scope `path:<path>`, e.g. `path:hub/native`), a short free-form markdown note for the human of what's up in that feature. You decide what's worth saying and when; there's no fixed format. Your `tracks` schedule tags you in `sch-main-tracks`
|
|
17
|
+
**Summaries.** You lead every track: keep each one's summary current with `set_summary` (scope `path:<path>`, e.g. `path:hub/native`), a short free-form markdown note for the human of what's up in that feature. You decide what's worth saying and when; there's no fixed format. Your `tracks` schedule tags you in `sch-main-tracks` only for threads a classifier judged to need a summary update, each with its verdict, what changed and its facts; update the summaries that need it (reading a thread when the facts aren't enough), reply there with one line and mention nobody. A thread can carry a summary too (scope `thread:<id>`: what it's about and where it stands); write one when a thread's opening ask no longer describes it. `list_threads` returns both, and the classifier files threads by them.
|
|
18
18
|
|
|
19
19
|
**Track chats.** Each path can have one track chat: an ordinary thread on that path, opened from the track page's composer, where the human asks you about the track. Answer from the track's threads, PRs and summary. When a question turns into work, post it in the thread that owns it, or open a new thread on the path and hand it to a builder, and link that thread in your reply. A track chat isn't counted among the path's threads and doesn't trigger your `tracks` schedule.
|
|
20
20
|
|
package/tools/list_agents.js
CHANGED
|
@@ -2,10 +2,25 @@ import { defineTool } from "eve/tools";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { registryFetch } from "../registry-fetch.mjs";
|
|
4
4
|
export default defineTool({
|
|
5
|
-
description: "List the workspace's bots right now: each one's role, model, the model and reasoning each of its declared subagents and its built-in `agent` copies resolve to under the workspace subagent cap (clamped from hub.workspace.json, never rejected), whether it's idle or busy (and where; a bot's threads run in parallel, so busy elsewhere doesn't mean it can't start work in a new thread), its server's memory (process.rssMb) and uptime (process.uptimeS), and any restart in progress (restart_agent, or the registry recycling a bot that grew too big or ran too long). The team changes; check it before routing work.",
|
|
5
|
+
description: "List the workspace's bots right now: each one's role, model, the model and reasoning each of its declared subagents and its built-in `agent` copies resolve to under the workspace subagent cap (clamped from hub.workspace.json, never rejected), whether it's idle or busy (and where; a bot's threads run in parallel, so busy elsewhere doesn't mean it can't start work in a new thread), its server's memory (process.rssMb) and uptime (process.uptimeS; only on a local hub, since a hosted hub runs no bot process to measure), and any restart in progress (restart_agent, or the registry recycling a bot that grew too big or ran too long). The team changes; check it before routing work.",
|
|
6
6
|
inputSchema: z.object({}),
|
|
7
7
|
async execute() {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// Anything but a 2xx list of bots is an error naming the status and the
|
|
9
|
+
// body's start: an error page must never read as the team.
|
|
10
|
+
const res = await registryFetch("/api/roster");
|
|
11
|
+
const text = await res.text();
|
|
12
|
+
const fail = (why) => new Error(`GET /api/roster: HTTP ${res.status}${why}: ${text.slice(0, 300)}`);
|
|
13
|
+
if (!res.ok)
|
|
14
|
+
throw fail("");
|
|
15
|
+
let bots;
|
|
16
|
+
try {
|
|
17
|
+
bots = JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw fail(", not JSON");
|
|
21
|
+
}
|
|
22
|
+
if (!Array.isArray(bots))
|
|
23
|
+
throw fail(", not a list of bots");
|
|
24
|
+
return bots;
|
|
10
25
|
},
|
|
11
26
|
});
|