@a-t-h-i/bot-lobby 0.6.57 → 0.6.58
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/README.md +17 -11
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/package.json +1 -1
- package/prompts/lobby-chat.md +39 -0
- package/prompts/master.md +7 -7
- package/prompts/reviewer.md +6 -6
- package/src/classifier/instance.ts +1 -1
- package/src/classifier/lobby.ts +37 -0
- package/src/classifier/triage.ts +2 -24
- package/src/execution/delegation.ts +2 -2
- package/src/index.ts +0 -3
- package/src/lobby/chat-agents.ts +293 -0
- package/src/lobby/feed.ts +63 -17
- package/src/lobby/host.ts +8 -4
- package/src/lobby/planner.ts +5 -5
- package/src/lobby/pr-review.ts +2 -2
- package/src/lobby/profile.ts +16 -0
- package/src/lobby/prompts.ts +56 -4
- package/src/lobby/recovery.ts +10 -42
- package/src/lobby/runtime.ts +19 -45
- package/src/lobby/service.ts +142 -13
- package/src/lobby/session-files.ts +4 -1
- package/src/lobby/topics.ts +0 -1
- package/src/master/master.ts +1 -1
- package/src/pi/commands.ts +6 -8
- package/src/pi/events.ts +11 -3
- package/src/pi/fresh-context.ts +1 -1
- package/src/pi/model-settings.ts +0 -2
- package/src/pi/model-support.ts +11 -2
- package/src/pi/start-task.ts +3 -9
- package/src/pi/switch.ts +1 -1
- package/src/schemas/configuration.ts +14 -24
- package/src/state/changes.ts +25 -23
- package/src/state/metrics.ts +5 -8
- package/src/state/recovery.ts +3 -3
- package/src/webui/api/index.ts +0 -18
- package/src/webui/api/lobby.ts +9 -6
- package/src/webui/dev/fake-service.ts +13 -61
- package/src/webui/dev/fixtures.ts +1 -4
- package/src/webui/protocol.ts +5 -9
- package/src/workflow/track.ts +1 -52
- package/src/workflow/workflow.ts +2 -2
- package/webui/dist/assets/{TerminalView-C8VC7KXy.js → TerminalView-D1GcmM8g.js} +1 -1
- package/webui/dist/assets/index-Dnz2q4t0.js +112 -0
- package/webui/dist/assets/index-nzFYIE8J.css +2 -0
- package/webui/dist/build.json +23 -35
- package/webui/dist/index.html +2 -2
- package/prompts/quickfix.md +0 -67
- package/src/lobby/quickfix.ts +0 -546
- package/src/pi/route.ts +0 -180
- package/src/webui/api/quickfix.ts +0 -49
- package/webui/dist/assets/index-Bs1_77_1.js +0 -114
- package/webui/dist/assets/index-DOLQ4FDd.css +0 -2
package/README.md
CHANGED
|
@@ -16,20 +16,26 @@ Requires **Pi 1.3.x** (1.3.0 or later).
|
|
|
16
16
|
|
|
17
17
|
## Use
|
|
18
18
|
|
|
19
|
-
1. In Pi, run `/bot-lobby
|
|
20
|
-
|
|
21
|
-
2.
|
|
22
|
-
|
|
23
|
-
and
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
1. In Pi, run `/bot-lobby` to open the lobby in your browser. `Ctrl+Shift+M` or `/bot-lobby on|off`
|
|
20
|
+
switches bot-lobby on or off; a new Pi session starts with it off.
|
|
21
|
+
2. Chat with the team in **Lobby**. Questions, code changes and small fixes happen right there: the
|
|
22
|
+
classifier hands each message to the agent whose area it is (a styling question to DESIGN, a
|
|
23
|
+
library question to RESEARCH, "where is this?" to a scout), and every reply is labelled with who
|
|
24
|
+
wrote it. Start a message with `@design`, `@dev`, `@qa`, `@research`, `@scout` or `@master` to pick
|
|
25
|
+
who answers yourself.
|
|
26
|
+
3. Type `/plan <idea>` to plan it with the panel in **Plan**, or `/task <request>` to start it as a
|
|
27
|
+
task for the team. Follow the agents in **Lobby** and **Tasks**; their steps fold into one line in
|
|
28
|
+
the conversation and open in place.
|
|
29
|
+
|
|
30
|
+
`/bot-lobby <request>` in Pi also starts a task. `--fast` / `--full` picks the workflow, and
|
|
31
|
+
`--branch` / `--worktree` isolates it in git. Workers never commit: the oracle commits only the
|
|
27
32
|
files the task changed, after the project's linter and a QA pass sized to the change's risk.
|
|
28
33
|
|
|
29
34
|
## The lobby
|
|
30
35
|
|
|
31
|
-
- **Lobby**: the
|
|
32
|
-
their reasoning.
|
|
36
|
+
- **Lobby**: the team chat. The oracle and the agents answer here, each reply under its agent's
|
|
37
|
+
name; each run of agent steps shows inline, and **Thinking** holds their reasoning. Typing `@` or
|
|
38
|
+
`/` at the start of a message opens a list of agents or commands to pick from.
|
|
33
39
|
- **Plan**: plan with a panel of agents. **View plan** (`V`) opens the draft in a slide-out, where
|
|
34
40
|
any line takes a comment.
|
|
35
41
|
- **Tasks**: every task across sessions as a list or a board, with its steps, timings and **Resume**.
|
|
@@ -48,7 +54,7 @@ Git opens pull requests at `#/git` and `#/git/<number>`, and issues at `#/git/is
|
|
|
48
54
|
addresses fall back to Lobby; Excalidraw integration is removed without deleting saved room links
|
|
49
55
|
or external boards.
|
|
50
56
|
|
|
51
|
-
The composer appears only on **Lobby**, **Tasks**, **Plan
|
|
57
|
+
The composer appears only on **Lobby**, **Tasks**, **Plan** and **Knowledge**, including
|
|
52
58
|
supported detail routes and Knowledge's Docs/Chat views. On Git, Metrics and Settings it stays mounted
|
|
53
59
|
but hidden and inert, preserving drafts and attachments (including uploads that finish while hidden)
|
|
54
60
|
without taking space.
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -36,6 +36,7 @@ naming the file it came from.
|
|
|
36
36
|
| `webui/src/components/reui/popover.tsx` | `apps/web/src/components/ui/popover.tsx` |
|
|
37
37
|
| `webui/src/app/Sidebar.tsx` | `apps/web/src/components/Sidebar.tsx`, `apps/web/src/components/ui/sidebar.tsx` |
|
|
38
38
|
| `webui/src/app/Composer.tsx` | `apps/web/src/components/chat/ComposerSurface.tsx`, `ChatComposer.tsx`, `ComposerPrimaryActions.tsx`, `ComposerControl.tsx` |
|
|
39
|
+
| `webui/src/app/composer/ComposerCommandMenu.tsx` | `apps/web/src/components/chat/ComposerCommandMenu.tsx`, `ComposerBanner.tsx` (the attached drawer surface), `composer-logic.ts` (the `/` and `@` trigger) |
|
|
39
40
|
| `webui/src/app/TerminalDrawer.tsx` | `apps/web/src/components/ThreadTerminalDrawer.tsx` (the drawer, its floating toolbar and terminal list) |
|
|
40
41
|
| `webui/src/tabs/lobby/InlineActivity.tsx` | `apps/web/src/components/chat/WorkLog.tsx` |
|
|
41
42
|
| `src/webui/palette-core.ts` (the built-in themes) | `packages/shared/src/themePalettes.ts` (Blossom is their T3 Chat theme, renamed) |
|
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Lobby Chat
|
|
2
|
+
|
|
3
|
+
You are answering the user directly in Bot Lobby's chat. There is no task,
|
|
4
|
+
brief, plan or review round here: the user talks to the team the way they
|
|
5
|
+
would talk to a colleague, and this message was given to you because it is
|
|
6
|
+
yours to answer (they named you with an @ mention, or the classifier judged
|
|
7
|
+
it your area). The Master (the oracle) and the other agents share this chat
|
|
8
|
+
and read your reply.
|
|
9
|
+
|
|
10
|
+
## How to work
|
|
11
|
+
|
|
12
|
+
- Read **Conversation so far** for context, then answer the **Message**. A
|
|
13
|
+
question gets an answer; a request for a change gets the change.
|
|
14
|
+
- Read only what you need, and follow the code's existing conventions. When
|
|
15
|
+
the message ends with **Likely files**, start there (and use
|
|
16
|
+
`find_relevant_files`, when you have it, before a broad search).
|
|
17
|
+
- When you need several reads or searches that do not depend on each other,
|
|
18
|
+
make them all in one turn: they run in parallel.
|
|
19
|
+
- Changes (when you may edit): make the smallest correct change that does
|
|
20
|
+
what was asked, with `edit`/`write`, never shell redirection or `sed -i`.
|
|
21
|
+
Do not refactor, rename or tidy anything else. Run a quick targeted check
|
|
22
|
+
when one exists (the nearest test, a typecheck) with a bash `timeout`;
|
|
23
|
+
never start dev servers, watchers or background processes. Do not commit
|
|
24
|
+
unless the user asks.
|
|
25
|
+
- If the message is ambiguous, take the most reasonable reading and say which
|
|
26
|
+
one you chose; do not stop to ask.
|
|
27
|
+
- If the work turns out large (many areas, a new dependency, an architecture
|
|
28
|
+
change, a decision only the user can make), do not start it: say what it
|
|
29
|
+
would take, and suggest `/plan` to plan it with the panel or `/task` to hand
|
|
30
|
+
it to the team.
|
|
31
|
+
- A bot-lobby task may be running in this working tree at the same time:
|
|
32
|
+
touch only the files the message needs, re-read a file right before you
|
|
33
|
+
edit it, and never revert changes you did not make.
|
|
34
|
+
|
|
35
|
+
## Your reply
|
|
36
|
+
|
|
37
|
+
Write to the user, in plain prose and Markdown, as short as the answer
|
|
38
|
+
allows: no report headings, no brief check. When you changed files, end with
|
|
39
|
+
a short list of them and what you ran to check, or say it was not checked.
|
package/prompts/master.md
CHANGED
|
@@ -460,12 +460,12 @@ nothing.
|
|
|
460
460
|
|
|
461
461
|
Not every change in the tree is this task's. Worker and QA reports end with
|
|
462
462
|
who changed each file, from bot-lobby's record of every agent's edits:
|
|
463
|
-
**planned** (this task's workers), **
|
|
464
|
-
|
|
465
|
-
fixes), **pre-existing** and **another task** (not this task's), and
|
|
463
|
+
**planned** (this task's workers), **lobby chat** (changes the user asked
|
|
464
|
+
for directly in the lobby chat: authorised, so never revert them or send them
|
|
465
|
+
back as fixes), **pre-existing** and **another task** (not this task's), and
|
|
466
466
|
**unattributed** (no agent recorded it: ask the user before counting it in or
|
|
467
|
-
reverting it). A QA finding about a
|
|
468
|
-
breaks this task.
|
|
467
|
+
reverting it). A QA finding about a lobby chat change is yours to act on only
|
|
468
|
+
when it breaks this task.
|
|
469
469
|
|
|
470
470
|
## Completion
|
|
471
471
|
|
|
@@ -485,8 +485,8 @@ because a Worker says it is done.
|
|
|
485
485
|
You commit your agents' work, not they: workers never commit. Call
|
|
486
486
|
`action=complete` with `message`, a commit message you write from what was
|
|
487
487
|
built, and the engine commits exactly the files this task's workers changed
|
|
488
|
-
(never ones that held changes before they started,
|
|
489
|
-
another task's) before calling the task done. Keep it concise: a first line of
|
|
488
|
+
(never ones that held changes before they started, the lobby chat's alone,
|
|
489
|
+
or another task's) before calling the task done. Keep it concise: a first line of
|
|
490
490
|
72 characters or fewer saying what changed, in the imperative ("Add login
|
|
491
491
|
rate limit"), and at most three short lines below it when the why is not
|
|
492
492
|
obvious. No file lists, step-by-step narration, test counts or sign-offs. A
|
package/prompts/reviewer.md
CHANGED
|
@@ -37,18 +37,18 @@ Verification (`- engine lint on the touched files — no new problems`).
|
|
|
37
37
|
|
|
38
38
|
## Change provenance
|
|
39
39
|
|
|
40
|
-
The working tree can hold changes that are not this task's: the user
|
|
41
|
-
|
|
40
|
+
The working tree can hold changes that are not this task's: the user asks for
|
|
41
|
+
changes in the lobby chat while tasks run, other tasks may run beside this
|
|
42
42
|
one, and there may have been uncommitted work before the task started. When
|
|
43
43
|
your context lists who changed each file (bot-lobby's own record of every
|
|
44
44
|
agent's `edit`/`write` calls), judge each change by its source:
|
|
45
45
|
|
|
46
46
|
- **planned**: this task's workers. Review it against the plan, scope
|
|
47
47
|
discipline included.
|
|
48
|
-
- **
|
|
49
|
-
outside this task's plan, so it is never scope creep or a
|
|
50
|
-
you never ask for it to be reverted. Mention it only if it
|
|
51
|
-
or its checks, naming the
|
|
48
|
+
- **lobby chat**: a change the user asked for directly in the lobby chat. It
|
|
49
|
+
is authorised and outside this task's plan, so it is never scope creep or a
|
|
50
|
+
rogue change, and you never ask for it to be reverted. Mention it only if it
|
|
51
|
+
breaks this task or its checks, naming the chat reply (`CHAT-3`).
|
|
52
52
|
- **another task** or **pre-existing**: not this task's work. Leave it alone
|
|
53
53
|
unless it breaks this task.
|
|
54
54
|
- **unattributed**: no agent recorded the edit (the user by hand, a shell
|
|
@@ -70,7 +70,7 @@ export function knowledgeFor(): KnowledgePicker {
|
|
|
70
70
|
return { ...knowledgePicker(classifier(), log), scripts: scriptPicker(classifier(), log) };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/** Shared scripts for a
|
|
73
|
+
/** Shared scripts for a lobby chat agent, with what Jev matched logged to the lobby's activity feed. */
|
|
74
74
|
export function scriptsPickerFor(): ScriptPicker {
|
|
75
75
|
return scriptPicker(classifier(), (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
|
|
76
76
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who answers a lobby chat message: the classifier reads the message (with
|
|
3
|
+
* the last few lines of the conversation) and picks the agent whose area it
|
|
4
|
+
* is, so a frontend question goes to DESIGN, a library question to RESEARCH,
|
|
5
|
+
* "how does this work" to a scout and a question about the project's saved
|
|
6
|
+
* knowledge to the oracle's knowledge lookup. Anything it is unsure of, and
|
|
7
|
+
* every message while it is off, goes to the Master.
|
|
8
|
+
*/
|
|
9
|
+
import type { ChatAgent } from "../lobby/prompts.ts";
|
|
10
|
+
import type { Classifier } from "./classifier.ts";
|
|
11
|
+
import { choice, choiceOf } from "./client.ts";
|
|
12
|
+
import { clip } from "./limits.ts";
|
|
13
|
+
|
|
14
|
+
/** Where a lobby message goes: an agent, or the oracle's knowledge lookup. */
|
|
15
|
+
export type ChatRoute = ChatAgent | "knowledge";
|
|
16
|
+
|
|
17
|
+
const ROUTES: Record<ChatRoute, string> = {
|
|
18
|
+
master: "The Master (the oracle): general conversation, the team's tasks and plans, anything across several areas of the project, or anything no single agent below clearly owns",
|
|
19
|
+
designer: "DESIGN: the frontend, meaning pages, components, styling, layout, UX, on-screen copy and browser code, whether a question about it or a change to it",
|
|
20
|
+
backend: "DEV: backend code, meaning APIs, data and databases, server logic, integrations, scripts, build and tooling, whether a question about it or a change to it",
|
|
21
|
+
qa: "QA: tests, meaning writing, running or fixing them, test strategy, how to verify a change, or checking work for bugs",
|
|
22
|
+
researcher: "RESEARCH: facts from outside the repository, meaning libraries, versions, third-party APIs, documentation, standards, or comparing tools",
|
|
23
|
+
scout: "SCOUT: understanding the existing code without changing it, meaning where something lives, how a part works, or tracing a flow through the code",
|
|
24
|
+
knowledge: "KNOWLEDGE: what the project's saved knowledge says, meaning its conventions, standards, past decisions and architecture notes",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The agent the classifier picks for `message`, when it is on and sure enough; undefined leaves it to the Master. */
|
|
28
|
+
export async function routeChatMessage(classifier: Classifier, message: string, recent: readonly string[], options: { at: number; signal?: AbortSignal }): Promise<{ route: ChatRoute; probability: number } | undefined> {
|
|
29
|
+
if (!classifier.enabled("lobby") || !message.trim()) return undefined;
|
|
30
|
+
const result = await classifier.ask("lobby", {
|
|
31
|
+
state: { message: clip(message, 4000), ...(recent.length > 0 ? { recent_conversation: clip(recent.join("\n"), 2000) } : {}) },
|
|
32
|
+
questions: { agent: choice("Which member of a software team should answer `message`, which the user typed into the team's chat? Read `recent_conversation`, when present, for what the message refers to.", ROUTES) },
|
|
33
|
+
}, options.signal ? { signal: options.signal } : {});
|
|
34
|
+
const picked = choiceOf(result?.answers, "agent");
|
|
35
|
+
if (!picked || !(picked.choice in ROUTES) || picked.probability < options.at) return undefined;
|
|
36
|
+
return { route: picked.choice as ChatRoute, probability: picked.probability };
|
|
37
|
+
}
|
package/src/classifier/triage.ts
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
* size, which domains it touches, whether it needs outside facts or a
|
|
4
4
|
* clarifying question, and what kind of work it is — in one call. The Master
|
|
5
5
|
* reads the answers as hints (so it can skip reasoning them out and skip
|
|
6
|
-
* scouts it does not need); nothing here becomes a workflow rule.
|
|
7
|
-
* size question holds back a quick fix that is really a task, and a clarify
|
|
6
|
+
* scouts it does not need); nothing here becomes a workflow rule. A clarify
|
|
8
7
|
* question whose recommended option is clearly right is answered here.
|
|
9
8
|
*/
|
|
10
9
|
import type { Domain } from "../schemas/agent.ts";
|
|
@@ -48,7 +47,7 @@ function sizeQuestion(subject: string) {
|
|
|
48
47
|
return score(`How big is the change ${subject} asks for, judged by what it would take to build and verify in this repository?`, SIZE_LEVELS);
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
/** Whether one agent can just do it
|
|
50
|
+
/** Whether one agent can just do it, or it needs the team: a self-contained build takes the fast track. */
|
|
52
51
|
function soloQuestion(subject: string) {
|
|
53
52
|
return noul(
|
|
54
53
|
`Could one engineer do ${subject} alone, right away — in one file or one area of the code — with no contract to agree between frontend and backend, no unfamiliar codebase to survey first, no risky change (security, data, payments, migrations) and no decision the user must make first?`,
|
|
@@ -143,27 +142,6 @@ export async function triageWithContext(classifier: Classifier, scope: FileScope
|
|
|
143
142
|
return files.length > 0 ? { ...triage, likelyFiles: files } : triage;
|
|
144
143
|
}
|
|
145
144
|
|
|
146
|
-
/**
|
|
147
|
-
* The size of a quick fix prompt, and how likely one engineer can do it alone,
|
|
148
|
-
* or undefined when the classifier is off or fails. A large prompt is held as
|
|
149
|
-
* a task unless it is still one engineer's work (a rich page in one file).
|
|
150
|
-
*/
|
|
151
|
-
export async function quickFixSize(classifier: Classifier, prompt: string, signal?: AbortSignal): Promise<{ size: TriageSize; confidence: number; solo?: number } | undefined> {
|
|
152
|
-
if (!classifier.enabled("triage")) return undefined;
|
|
153
|
-
const { quickFixLargeAt: largeAt, quickFixAt } = classifier.config.thresholds;
|
|
154
|
-
const result = await classifier.ask("triage", { state: { prompt: clip(prompt, 6000) }, questions: { size: sizeQuestion("`prompt`"), solo: soloQuestion("`prompt`") } }, {
|
|
155
|
-
...(signal ? { signal } : {}),
|
|
156
|
-
saved: (answers) => {
|
|
157
|
-
const sized = scoreOf(answers, "size");
|
|
158
|
-
return sized && sized.level >= TRIAGE_SIZES.length - 1 && sized.confidence >= largeAt && (yesOf(answers, "solo") ?? 0) < quickFixAt ? 1 : 0;
|
|
159
|
-
},
|
|
160
|
-
});
|
|
161
|
-
const size = scoreOf(result?.answers, "size");
|
|
162
|
-
if (!size) return undefined;
|
|
163
|
-
const solo = yesOf(result?.answers, "solo");
|
|
164
|
-
return { size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!, confidence: size.confidence, ...(solo !== undefined ? { solo } : {}) };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
145
|
const HINT = 0.5;
|
|
168
146
|
|
|
169
147
|
/** The one-line path the triage suggests to the Master. */
|
|
@@ -43,9 +43,9 @@ export function canDelegate(agent: { role: Role; domain: Domain; depth?: number
|
|
|
43
43
|
return agent.role === "reviewer" && agent.domain === "qa";
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/** A task's DESIGN/DEV workers (not their children, nor
|
|
46
|
+
/** A task's DESIGN/DEV workers (not their children, nor lobby chat agents) may consult a stronger model, when the setting is on. */
|
|
47
47
|
export function canConsult(agent: { runId: string; taskId: string; role: Role; domain: Domain; depth?: number }, enabled: boolean): boolean {
|
|
48
|
-
// A lobby
|
|
48
|
+
// A lobby chat agent runs as its own task: it is one agent answering the user, not a task's worker.
|
|
49
49
|
return enabled && agent.taskId !== agent.runId && (agent.depth ?? 1) === 1 && agent.role === "worker" && (agent.domain === "designer" || agent.domain === "backend");
|
|
50
50
|
}
|
|
51
51
|
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-ag
|
|
|
4
4
|
import { registerCommands } from "./pi/commands.ts";
|
|
5
5
|
import { registerLifecycle } from "./pi/events.ts";
|
|
6
6
|
import { registerOrchestrateTool } from "./pi/tools.ts";
|
|
7
|
-
import { registerRouteTool } from "./pi/route.ts";
|
|
8
7
|
import { onTransition } from "./state/task-state.ts";
|
|
9
8
|
import { releaseAttachments } from "./state/attachments.ts";
|
|
10
9
|
import { detectProjectRoot } from "./state/project.ts";
|
|
@@ -46,8 +45,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
46
45
|
});
|
|
47
46
|
registerCommands(pi, CONFIG_DIR_NAME);
|
|
48
47
|
registerOrchestrateTool(pi, CONFIG_DIR_NAME);
|
|
49
|
-
// A new request one agent can do alone: the oracle confirms, and the lobby hands it to the quick-fix agent.
|
|
50
|
-
registerRouteTool(pi, CONFIG_DIR_NAME);
|
|
51
48
|
// The questionnaire the oracle (and pi without a task) asks the user with; no other extension is needed for it.
|
|
52
49
|
registerAskTool(pi, undefined, CONFIG_DIR_NAME);
|
|
53
50
|
// The web tools, for the researcher and for pi without a task (the oracle leaves them to the researcher).
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lobby chat's agents: a message the classifier (or an @ mention) gives
|
|
3
|
+
* to DESIGN, DEV, QA, RESEARCH or a scout is answered by that agent itself,
|
|
4
|
+
* on its own model, thinking, prompt and grants, with the conversation so
|
|
5
|
+
* far as context. The builders may change files; RESEARCH and the scout only
|
|
6
|
+
* read. Replies run one at a time in the order they were asked, so two agents
|
|
7
|
+
* never edit the tree at once; each streams plain-words steps into the
|
|
8
|
+
* activity feed, its reply joins the conversation under its name, and every
|
|
9
|
+
* finished reply lands in the metrics log. The Master's own replies are pi's
|
|
10
|
+
* turns, not this queue's.
|
|
11
|
+
*/
|
|
12
|
+
import { loadPrompt } from "../prompts/loader.ts";
|
|
13
|
+
import { compilePrompt } from "../prompts/compiler.ts";
|
|
14
|
+
import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
|
|
15
|
+
import { MAX_CHILDREN } from "../execution/delegation.ts";
|
|
16
|
+
import { describeToolCall } from "../pi/activity.ts";
|
|
17
|
+
import { appendMetrics, type MetricRecord, type MetricStatus } from "../state/metrics.ts";
|
|
18
|
+
import { appendChange, EditLog } from "../state/changes.ts";
|
|
19
|
+
import type { FileHinter } from "../classifier/files.ts";
|
|
20
|
+
import type { Domain, Role } from "../schemas/agent.ts";
|
|
21
|
+
import type { LobbyFeed } from "./feed.ts";
|
|
22
|
+
import type { LobbyRunProfile } from "./profile.ts";
|
|
23
|
+
import { CHAT_AGENT_SOURCES, type ChatAgent } from "./prompts.ts";
|
|
24
|
+
import { PLANNER_TOOLS, RESEARCH_PANEL_TOOLS } from "./planner.ts";
|
|
25
|
+
|
|
26
|
+
/** The agents this queue runs: every chat agent but the Master. */
|
|
27
|
+
export type ChatWorker = Exclude<ChatAgent, "master">;
|
|
28
|
+
|
|
29
|
+
/** DESIGN, DEV and QA may change files; RESEARCH and the scout only read. */
|
|
30
|
+
export const CHAT_BUILDER_TOOLS: readonly string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
31
|
+
|
|
32
|
+
const TOOLS: Record<ChatWorker, readonly string[]> = {
|
|
33
|
+
designer: CHAT_BUILDER_TOOLS,
|
|
34
|
+
backend: CHAT_BUILDER_TOOLS,
|
|
35
|
+
qa: CHAT_BUILDER_TOOLS,
|
|
36
|
+
researcher: RESEARCH_PANEL_TOOLS,
|
|
37
|
+
scout: PLANNER_TOOLS,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** What each agent owns, appended to its prompt. */
|
|
41
|
+
const SEATS: Record<ChatWorker, string> = {
|
|
42
|
+
designer: "You are DESIGN: the frontend and the user's experience — pages, components, styling, layout, states, copy on screen, accessibility and everything that runs in the browser.",
|
|
43
|
+
backend: "You are DEV: the backend and the code — APIs, data and migrations, server logic, integrations, errors and edge cases, security, performance, build and tooling.",
|
|
44
|
+
qa: "You are QA: tests and how work is judged — writing, running and fixing tests, the test strategy, edge cases and failure modes, and checking a change for bugs.",
|
|
45
|
+
researcher: "You are RESEARCH: facts outside the repository — libraries and versions, standards, APIs and documentation, known pitfalls and prior art. Use the web tools when you have them and cite a URL for every external claim. You never change files.",
|
|
46
|
+
scout: "You are a SCOUT: you read the repository to answer how it works — where things live, how a part fits together, what calls what — citing paths and symbols. You never change files.",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const DOMAIN_OF: Record<ChatWorker, Domain> = { designer: "designer", backend: "backend", qa: "qa", researcher: "backend", scout: "backend" };
|
|
50
|
+
const ROLE_OF: Record<ChatWorker, Role> = { designer: "worker", backend: "worker", qa: "worker", researcher: "researcher", scout: "scout" };
|
|
51
|
+
|
|
52
|
+
/** Lines of conversation an agent is given, newest last. */
|
|
53
|
+
export const CHAT_CONTEXT_LINES = 12;
|
|
54
|
+
/** Longest line of earlier conversation it is given. */
|
|
55
|
+
const CONTEXT_LINE_CHARS = 1200;
|
|
56
|
+
export const MAX_CHAT_JOBS = 30;
|
|
57
|
+
|
|
58
|
+
export type ChatJobStatus = "queued" | "running" | "success" | "failed" | "cancelled" | "timeout";
|
|
59
|
+
|
|
60
|
+
export interface ChatJob {
|
|
61
|
+
id: string;
|
|
62
|
+
agent: ChatWorker;
|
|
63
|
+
/** The user's message, as they sent it (without the @ mention). */
|
|
64
|
+
text: string;
|
|
65
|
+
/** Why it came to this agent: `you named @design`, `the classifier (0.82)`. */
|
|
66
|
+
why: string;
|
|
67
|
+
status: ChatJobStatus;
|
|
68
|
+
createdAt: number;
|
|
69
|
+
startedAt?: number;
|
|
70
|
+
finishedAt?: number;
|
|
71
|
+
model?: string;
|
|
72
|
+
thinking?: string;
|
|
73
|
+
tools: number;
|
|
74
|
+
reply?: string;
|
|
75
|
+
error?: string;
|
|
76
|
+
/** Files it changed, as shown (relative to the project). */
|
|
77
|
+
files?: string[];
|
|
78
|
+
usage?: { input: number; output: number; cost: number; turns: number };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ChatAgentsDeps {
|
|
82
|
+
cwd: string;
|
|
83
|
+
root: string;
|
|
84
|
+
configDir: string;
|
|
85
|
+
/** Model, thinking and grants for an agent, read from settings when its reply starts. */
|
|
86
|
+
profile: (agent: ChatWorker) => LobbyRunProfile;
|
|
87
|
+
/** The conversation so far, oldest first, as lines (`You: …`, `DESIGN: …`). */
|
|
88
|
+
history: () => readonly string[];
|
|
89
|
+
/** A reply is done: put it in the conversation (and the session, so the Master reads it too). */
|
|
90
|
+
onReply?: (job: ChatJob) => void;
|
|
91
|
+
onChange?: () => void;
|
|
92
|
+
feed?: LobbyFeed;
|
|
93
|
+
/** Likely files for the message, and the lookup tool, while the classifier's file hints are on. */
|
|
94
|
+
hints?: FileHinter;
|
|
95
|
+
stallTimeoutMs?: number;
|
|
96
|
+
toolStallTimeoutMs?: number;
|
|
97
|
+
runProcess?: ProcessRunner;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** An agent's system prompt: its domain's layers (the global one for RESEARCH and scouts), the lobby chat role and its seat. */
|
|
101
|
+
export function chatAgentPrompt(agent: ChatWorker, instructions?: string): string {
|
|
102
|
+
const custom = instructions?.trim();
|
|
103
|
+
const base = agent === "researcher" || agent === "scout"
|
|
104
|
+
? [loadPrompt("global.md"), custom ? `## Custom Instructions\n\n${custom}` : ""].filter(Boolean).join("\n\n---\n\n")
|
|
105
|
+
: compilePrompt({ domain: agent, task: "", ...(custom ? { instructions: custom } : {}) });
|
|
106
|
+
return [base, loadPrompt("lobby-chat.md"), `## Your seat\n\n${SEATS[agent]}`].join("\n\n---\n\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What the agent is asked: the conversation so far, then the message. */
|
|
110
|
+
export function chatAgentTask(text: string, history: readonly string[], likely = ""): string {
|
|
111
|
+
const lines = history.slice(-CHAT_CONTEXT_LINES).map((line) => (line.length > CONTEXT_LINE_CHARS ? `${line.slice(0, CONTEXT_LINE_CHARS - 1)}…` : line));
|
|
112
|
+
return [
|
|
113
|
+
lines.length > 0 ? `## Conversation so far\n\n${lines.join("\n\n")}` : "",
|
|
114
|
+
`## Message\n\n${text.trim()}`,
|
|
115
|
+
likely,
|
|
116
|
+
].filter(Boolean).join("\n\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isActive(job: ChatJob): boolean {
|
|
120
|
+
return job.status === "queued" || job.status === "running";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class ChatAgents {
|
|
124
|
+
jobs: ChatJob[] = [];
|
|
125
|
+
private readonly deps: ChatAgentsDeps;
|
|
126
|
+
private readonly controllers = new Map<string, AbortController>();
|
|
127
|
+
private readonly edits = new Map<string, EditLog>();
|
|
128
|
+
private counter = 0;
|
|
129
|
+
|
|
130
|
+
constructor(deps: ChatAgentsDeps) {
|
|
131
|
+
this.deps = deps;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get running(): ChatJob | undefined {
|
|
135
|
+
return this.jobs.find((job) => job.status === "running");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Any reply under way or waiting. */
|
|
139
|
+
get busy(): boolean {
|
|
140
|
+
return this.jobs.some(isActive);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Ask an agent; it answers at once when no other reply is under way, else after the ones before it. */
|
|
144
|
+
submit(agent: ChatWorker, text: string, why: string, now = Date.now()): ChatJob {
|
|
145
|
+
const body = text.trim();
|
|
146
|
+
if (!body) throw new Error("a message needs some text");
|
|
147
|
+
const job: ChatJob = { id: `CHAT-${++this.counter}`, agent, text: body, why, status: "queued", createdAt: now, tools: 0 };
|
|
148
|
+
this.jobs = [...this.jobs, job].filter((entry, index, all) => isActive(entry) || index >= all.length - MAX_CHAT_JOBS);
|
|
149
|
+
this.changed();
|
|
150
|
+
this.pump();
|
|
151
|
+
return job;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Stop the reply under way and drop the waiting ones. */
|
|
155
|
+
cancelAll(): void {
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
for (const job of this.jobs) {
|
|
158
|
+
if (job.status !== "queued") continue;
|
|
159
|
+
job.status = "cancelled";
|
|
160
|
+
job.finishedAt = now;
|
|
161
|
+
}
|
|
162
|
+
for (const controller of this.controllers.values()) controller.abort();
|
|
163
|
+
this.changed();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private changed(): void {
|
|
167
|
+
this.deps.onChange?.();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private pump(): void {
|
|
171
|
+
if (this.running) return;
|
|
172
|
+
const next = this.jobs.find((job) => job.status === "queued");
|
|
173
|
+
if (next) void this.run(next);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private onEvent(job: ChatJob, event: PiStreamEvent): void {
|
|
177
|
+
const source = CHAT_AGENT_SOURCES[job.agent];
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
if (event.type === "tool_execution_start") {
|
|
180
|
+
job.tools += 1;
|
|
181
|
+
const edits = this.edits.get(job.id);
|
|
182
|
+
if (edits?.note(event.toolName, event.args)) job.files = edits.shown();
|
|
183
|
+
this.deps.feed?.step(source, describeToolCall(event.toolName, event.args), job.id, now);
|
|
184
|
+
} else if (event.type === "thought") this.deps.feed?.liveThought(source, event.text, job.id, now);
|
|
185
|
+
else if (event.type === "retry") this.deps.feed?.step(source, `provider retry ${event.attempt}/${event.maxAttempts}`, job.id, now);
|
|
186
|
+
else return;
|
|
187
|
+
this.changed();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async run(job: ChatJob): Promise<void> {
|
|
191
|
+
const profile = this.deps.profile(job.agent);
|
|
192
|
+
const controller = new AbortController();
|
|
193
|
+
this.controllers.set(job.id, controller);
|
|
194
|
+
job.status = "running";
|
|
195
|
+
job.startedAt = Date.now();
|
|
196
|
+
job.thinking = profile.thinking;
|
|
197
|
+
if (profile.model) job.model = profile.model;
|
|
198
|
+
this.edits.set(job.id, new EditLog(this.deps.cwd));
|
|
199
|
+
this.changed();
|
|
200
|
+
// The message itself is the last line of the history; the task names it on its own.
|
|
201
|
+
const history = this.deps.history();
|
|
202
|
+
const earlier = history.at(-1)?.endsWith(job.text) ? history.slice(0, -1) : history;
|
|
203
|
+
try {
|
|
204
|
+
const likely = await this.likely(job.text, controller.signal);
|
|
205
|
+
const result = await runPiAgent(
|
|
206
|
+
{
|
|
207
|
+
cwd: this.deps.cwd,
|
|
208
|
+
task: chatAgentTask(job.text, earlier, likely),
|
|
209
|
+
systemPrompt: chatAgentPrompt(job.agent, profile.instructions),
|
|
210
|
+
tools: [...TOOLS[job.agent], ...(this.deps.hints?.tools() ?? [])],
|
|
211
|
+
model: profile.model,
|
|
212
|
+
thinking: profile.thinking,
|
|
213
|
+
mcpTools: profile.mcpTools,
|
|
214
|
+
...(profile.autoCompact === false ? { autoCompact: false } : {}),
|
|
215
|
+
...(profile.compactAt ? { compactAt: profile.compactAt } : {}),
|
|
216
|
+
...(profile.harness ? { harness: profile.harness } : {}),
|
|
217
|
+
...(profile.skills ? { skills: profile.skills } : {}),
|
|
218
|
+
delegationQuota: { used: MAX_CHILDREN },
|
|
219
|
+
agent: { runId: job.id, taskId: job.id, domain: DOMAIN_OF[job.agent], role: ROLE_OF[job.agent] },
|
|
220
|
+
signal: controller.signal,
|
|
221
|
+
stallTimeoutMs: this.deps.stallTimeoutMs,
|
|
222
|
+
toolStallTimeoutMs: this.deps.toolStallTimeoutMs,
|
|
223
|
+
onEvent: (event) => this.onEvent(job, event),
|
|
224
|
+
},
|
|
225
|
+
this.deps.runProcess ?? spawnPiProcess,
|
|
226
|
+
);
|
|
227
|
+
job.status = result.status;
|
|
228
|
+
job.reply = result.output.trim() || undefined;
|
|
229
|
+
job.error = result.error;
|
|
230
|
+
job.usage = result.usage;
|
|
231
|
+
if (result.model) job.model = result.model;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
job.status = "failed";
|
|
234
|
+
job.error = (error as Error).message;
|
|
235
|
+
} finally {
|
|
236
|
+
this.controllers.delete(job.id);
|
|
237
|
+
this.finish(job);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The Likely files block for a message, or "" (no hints, nothing stands out, out of time). */
|
|
242
|
+
private async likely(text: string, signal: AbortSignal): Promise<string> {
|
|
243
|
+
try {
|
|
244
|
+
return (await this.deps.hints?.block(text, signal)) ?? "";
|
|
245
|
+
} catch {
|
|
246
|
+
return "";
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private finish(job: ChatJob): void {
|
|
251
|
+
job.finishedAt = Date.now();
|
|
252
|
+
const source = CHAT_AGENT_SOURCES[job.agent];
|
|
253
|
+
const edited = this.edits.get(job.id)?.list() ?? [];
|
|
254
|
+
this.edits.delete(job.id);
|
|
255
|
+
// What it edited, even when it failed, is on record for a task's QA gate as the user's own request.
|
|
256
|
+
if (edited.length > 0) {
|
|
257
|
+
appendChange(this.deps.root, this.deps.configDir, {
|
|
258
|
+
source: "chat",
|
|
259
|
+
id: job.id,
|
|
260
|
+
what: `${source}: ${job.text.split("\n").find((line) => line.trim())?.trim() ?? ""}`,
|
|
261
|
+
files: edited,
|
|
262
|
+
startedAt: new Date(job.startedAt ?? job.createdAt).toISOString(),
|
|
263
|
+
finishedAt: new Date(job.finishedAt).toISOString(),
|
|
264
|
+
status: job.status,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
this.deps.feed?.end(job.id, job.status !== "success");
|
|
268
|
+
this.deps.feed?.settleThought(job.id);
|
|
269
|
+
if (job.status !== "success") this.deps.feed?.log(source, `reply ${job.status}${job.error ? ` — ${job.error.split("\n")[0]}` : ""}`, job.status === "cancelled" ? "warning" : "error", job.finishedAt);
|
|
270
|
+
appendMetrics(this.deps.root, this.deps.configDir, [chatMetric(job)]);
|
|
271
|
+
this.deps.onReply?.(job);
|
|
272
|
+
this.changed();
|
|
273
|
+
this.pump();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function chatMetric(job: ChatJob): MetricRecord {
|
|
278
|
+
const started = job.startedAt ?? job.createdAt;
|
|
279
|
+
const status: MetricStatus = job.status === "queued" || job.status === "running" ? "failed" : job.status;
|
|
280
|
+
return {
|
|
281
|
+
id: `${job.id}-${started}`,
|
|
282
|
+
kind: "chat",
|
|
283
|
+
agent: CHAT_AGENT_SOURCES[job.agent],
|
|
284
|
+
...(job.model ? { model: job.model } : {}),
|
|
285
|
+
...(job.thinking ? { thinking: job.thinking } : {}),
|
|
286
|
+
status,
|
|
287
|
+
startedAt: new Date(started).toISOString(),
|
|
288
|
+
durationMs: Math.max(0, (job.finishedAt ?? started) - started),
|
|
289
|
+
...(job.usage?.turns ? { turns: job.usage.turns } : {}),
|
|
290
|
+
tools: job.tools,
|
|
291
|
+
...(job.usage ? { input: job.usage.input, output: job.usage.output, cost: job.usage.cost } : {}),
|
|
292
|
+
};
|
|
293
|
+
}
|