Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/server/src/agent/arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,17 +356,21 @@ export function openDecision(raw: unknown) {
let args = record(raw, "open_decision arguments");
fields(
args,
["revision", "key", "question", "description"],
["revision", "key", "question", "description", "blocks"],
["revision", "key", "question"],
"open_decision arguments",
);
let blocks = Object.hasOwn(args, "blocks")
? array(args.blocks, "blocks", "block").map((item, index) => block(item, `blocks[${index}]`))
: [];
return {
revision: integer(args.revision, "revision"),
key: text(args.key, "key"),
question: text(args.question, "question"),
...(Object.hasOwn(args, "description")
? { description: args.description === "" ? "" : text(args.description, "description", 300) }
: {}),
...(blocks[0] ? { block: blocks[0] } : {}),
};
}

Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/agent/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,12 @@ test("read_plan and edit_plan name the room's document as read_document does", a
}

await storage.channels.rename({ id: channel.id, title: "Renamed plan", now });
let renamed = { id: channel.id, url: "/documents/owner/repository/renamed-plan" };
// The current link is `url`; the link it had before the rename is named as earlier.
let renamed = {
id: channel.id,
url: "/documents/owner/repository/renamed-plan",
earlier_urls: ["/documents/owner/repository/test-plan"],
};
let read = await call("read_plan", {});
expect(read.document).toEqual(renamed);
let edited = await call("edit_plan", {
Expand Down
22 changes: 20 additions & 2 deletions apps/server/src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,9 @@ export const documentTools = {
contextSchema: roomContext,
description: "Read the plan: its revision, canonical source, the top-level blocks you can "
+ "address when editing, and the questions it holds. Read before editing — "
+ "`edit_plan` refuses a batch aimed at a revision that has moved on.",
+ "`edit_plan` refuses a batch aimed at a revision that has moved on. `document.url` is "
+ "this document's current link; `document.earlier_urls`, when present, are links it had "
+ "before a rename, which still open it.",
inputSchema: jsonSchema({ type: "object", properties: {}, additionalProperties: false }),
// Reading the document the agent is here to write is not a decision
// anybody needs to approve.
Expand Down Expand Up @@ -458,7 +460,9 @@ export const documentTools = {
+ "Use for choices such as how onboarding should work, not factual questions the "
+ "repository or research can settle. A recommendation is not agreement: only a "
+ "person pressing Save resolves this card. For planning questions beside prose, prefer "
+ "open_decisions, which also follows saved answers into the related prose. Reuse the same key when "
+ "open_decisions, which also follows saved answers into the related prose. Name the "
+ "block from read_plan the question concerns, such as its section heading; without one "
+ "the card goes beside the document's first section. Reuse the same key when "
+ "retrying the same capture. Do not use ask for the same decision as well.",
inputSchema: jsonSchema({
type: "object",
Expand All @@ -471,6 +475,20 @@ export const documentTools = {
},
question: { type: "string", minLength: 1, maxLength: 100 },
description: { type: "string", maxLength: 300 },
blocks: {
type: "array",
maxItems: 1,
description: "The block this decision concerns, from read_plan.",
items: {
type: "object",
properties: {
index: { type: "integer", minimum: 0 },
digest: { type: "string", pattern: "^sha256:[0-9a-f]{64}$" },
},
required: ["index", "digest"],
additionalProperties: false,
},
},
},
required: ["revision", "key", "question"],
additionalProperties: false,
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/chat/invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,21 @@ test("a run first reported as ended gets one finish message and never a repeat",
expect(spoken(context, "lint failed after 10 min.")).toBe(1);
});

test("a run that fails within the second it started is reported in seconds, not as a minute", async () => {
let { context, user } = await setup(configured(ATOMIC));
let planner = runningSession();
context.openPlannerSession = async () => ({ ok: true, value: planner.session });
await Chat.invoke(context, user, "Run the workflow");
await context.chat.running;
let quick = { ...card("failed"), id: "run-2", name: "lint", updated: 1_000, ended: 1_000 };
let short = { ...card("failed"), id: "run-3", name: "check", updated: 1_012, ended: 1_012 };
planner.set({ active: ["run-1"], paused: [], cards: [quick, short] });
await tick();
expect(spoken(context, "lint failed after less than a second.")).toBe(1);
expect(spoken(context, "check failed after 12 s.")).toBe(1);
expect(context.chat.entries.some(entry => entry.text.endsWith("after 1 min."))).toBe(false);
});

test("a run reported finished before its root ended gets no finish message until it does", async () => {
let { context, user } = await setup(configured(ATOMIC));
let planner = runningSession();
Expand Down
79 changes: 79 additions & 0 deletions apps/server/src/chat/remote-planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import {
scriptedPlanner,
} from "../testing/agent-link";
import * as Chat from "./service";
import * as Question from "@chopin/question";
import { answerTurns } from "../questions/answer-turns";
import * as Questions from "../questions/service";
import * as Store from "../questions/store";
import { member } from "../questions/text-response.test-fixtures";

import type { Server } from "bun";
import type { AgentClient } from "@chopin/agent-link/client";
Expand Down Expand Up @@ -373,3 +378,77 @@ test("@chopin and invoke_planner wait for a Planner that is starting, then run o
expect(await room.send("Just talking", "room")).toMatchObject({ kind: "chat:send" });
expect(started).toBe(2);
});

test("saving a decision the Planner left open starts one turn on the attached Planner carrying the answer", async () => {
let room = await remoteRoom();
let { context } = room;
let script = scriptedPlanner();
let client = await attach(room.server.url, room.channel.id, script.handlers);
cleanups.push(() => client.detach());
let id = await Questions.capture(context.plan, context.server, context.room, {
key: "blocked-run-next-step",
question: "How should the wrong sentence be fixed?",
}, { revision: context.plan.revision });
let turns = answerTurns({
start: async (_channelId, compose) => {
let instruction = compose(context.plan);
if (!instruction) return false;
await Chat.instruct(
context,
instruction.handle,
instruction.text,
instruction.said,
instruction.about,
);
return true;
},
});
let handing: Promise<void>[] = [];
let save = async () => {
let actor = member("ana");
let entry = Store.get(context.plan.questions, id)!;
let question = entry.definition.questions[0]!;
let fork = entry.model.fork() as Question.Model;
fork.api.str([question.id, "custom"]).ins(0, "Start a new run for it.");
await Questions.edit(context.plan, actor.ws, {
kind: "question:edit",
ts: 0,
rid: "edit",
id,
patch: [...fork.api.flush().toBinary()],
});
await Questions.submit(
context.plan,
context.server,
context.room,
actor.ws,
{
kind: "question:submit",
ts: 0,
rid: "save",
id,
revision: Store.get(context.plan.questions, id)!.revision,
},
undefined,
(answered, generation) => {
handing.push(turns.answered(context.room, answered, generation));
handing.push(turns.answered(context.room, answered, generation));
},
);
expect(actor.frames.at(-1)).toMatchObject({ kind: "question:submit", ok: true });
};

await save();
await Promise.all(handing);
await context.chat.running;

expect(script.turns.map(turn => turn.prompt)).toEqual([
expect.stringContaining(JSON.stringify("Start a new run for it.")),
]);
expect(script.turns[0]!.prompt).toContain(
JSON.stringify("How should the wrong sentence be fixed?"),
);
expect(context.chat.entries.map(entry => entry.text)).toContain(
'@ana answered "How should the wrong sentence be fixed?"',
);
});
32 changes: 32 additions & 0 deletions apps/server/src/chat/run-duration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test";

import { runDuration } from "./run-duration";

test.each([
[0.124, "0.1 s"],
[0.96, "1 s"],
[12, "12 s"],
[12.4, "12 s"],
[59.4, "59 s"],
])("a run of %p seconds reads %p", (seconds, text) => {
expect(runDuration(seconds)).toBe(text);
});

test("a run that ended within the second it started does not claim a minute", () => {
// Run cards carry whole seconds, so a 0.124 s run arrives as zero.
expect(runDuration(0)).toBe("less than a second");
expect(runDuration(0.04)).toBe("less than a second");
});

test.each([
[59.6, "1 min"],
[60, "1 min"],
[600, "10 min"],
[2_383, "40 min"],
])("a run of %p seconds reads in minutes as %p", (seconds, text) => {
expect(runDuration(seconds)).toBe(text);
});

test("a clock that went backwards reads as no time rather than a negative one", () => {
expect(runDuration(-5)).toBe("less than a second");
});
13 changes: 13 additions & 0 deletions apps/server/src/chat/run-duration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* How long a workflow run took, for its end notice: seconds under a minute, then minutes.
* Run cards carry whole seconds, so a run that ended in the second it started reads as less
* than a second rather than being rounded up to a minute.
*/
export function runDuration(seconds: number): string {
let tenths = Math.round(Math.max(0, seconds) * 10) / 10;
if (tenths === 0) return "less than a second";
if (tenths < 1) return `${tenths} s`;
let whole = Math.round(tenths);
if (whole < 60) return `${whole} s`;
return `${Math.round(tenths / 60)} min`;
}
6 changes: 4 additions & 2 deletions apps/server/src/chat/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { broadcast, fail, reply, tell } from "../wire";
import { MAX_MESSAGE_BYTES } from "./limits";
import { contributed } from "./initiative";
import { checkPlannerReply, publishPlannerReplies } from "./reply-policy";
import { runDuration } from "./run-duration";
import type { InitiativeSchedule } from "./initiative-schedule";

import type { Server } from "bun";
Expand Down Expand Up @@ -1610,8 +1611,9 @@ async function publishReport(
let ended = ENDED_RUN[run.status];
let previous = before.get(run.id);
if (!ended || (previous && ENDED_RUN[previous])) continue;
let minutes = Math.max(1, Math.round(((run.ended ?? run.updated) - run.started) / 60));
let said = `${run.name} ${ended} after ${minutes} min.`;
let said = `${run.name} ${ended} after ${
runDuration((run.ended ?? run.updated) - run.started)
}.`;
if (followUp && !releasing && NEEDS_FOLLOW_UP[run.status] && known.get(run.id) === session) {
attention.push({ run, said });
continue;
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import * as Marks from "./comments/inject";
import * as Questions from "./questions/service";
import * as Explore from "./questions/explore";
import { runningFindingRefs } from "./questions/service-findings";
import { answerTurns } from "./questions/answer-turns";
import { deterministicChannelId } from "./channels/id";
import type { Research as ResearchWire } from "@chopin/protocol";
import { investigationFinding } from "./experiments/document";
Expand Down Expand Up @@ -373,6 +374,27 @@ const spikeSettlement = spikeSettler({
),
});

/**
* Hand a saved answer to a decision the Planner left open to the Planner, as a turn it acts on.
* Like other server-authored turns it runs only under the existing owner, and under the remote
* harness only while a Planner is attached; one that found no Planner starts once one is.
*/
const decisionAnswers = answerTurns({
start: async (channelId, compose) => {
if (!config.agent) return "drop";
let channel = await storage.channels.get(channelId);
if (!channel || channel.parentChannelId) return "drop";
if (plannerAttachments && !await plannerAttachments.confirmed(channelId)) return false;
return serverInstruction(channelId, compose);
},
});

function answerDecision(channelId: string, id: string, generation: number): void {
void decisionAnswers.answered(channelId, id, generation).catch(err =>
console.error("chopin: could not hand a saved decision to the Planner -", err)
);
}

function settlePendingSpikes(channelId: string): void {
if (!config.agent || !config.liveSpikes || !experiments) return;
void spikeSettlement.pending(channelId).catch(err =>
Expand All @@ -385,6 +407,9 @@ function ownerAvailable(channelId: string): Promise<void> {
let opened = Rooms.get(channelId)?.plan;
if (opened) conversationRuntime.wake(opened);
settlePendingSpikes(channelId);
void decisionAnswers.pending(channelId).catch(err =>
console.error("chopin: could not hand saved decisions to the Planner -", err)
);
void reviewCompletedInvestigations(channelId);
void currentDocumentTarget(channelId).then(target => {
if (target) buildReadiness?.schedule(target);
Expand Down Expand Up @@ -1249,6 +1274,7 @@ async function receive(ws: Socket, raw: string): Promise<void> {
ws,
frame,
intent => conversationRuntime.contexts.remember(opened, intent.trigger, claimant),
(id, generation) => answerDecision(room.id, id, generation),
);
}
return;
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/plan/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,24 @@ export function hasProse(target: Document): boolean {
);
}

/**
* The block a card about no particular passage goes beside: the first section heading after an
* opening title, else the first block of prose. A document with only a title, or nothing, has
* no section, and such a card stays where it was put.
*/
export function firstSection(target: Document): { index: number; digest: string } | undefined {
let blocks = parse(project(target)).children;
let body = blocks[0]?.type === "heading" && blocks[0].depth === 1 ? 1 : 0;
let index = blocks.findIndex((node, at) => at >= body && node.type === "heading");
if (index < 0) {
index = blocks.findIndex((node, at) =>
at >= body && (node.type !== "mdxJsxFlowElement"
|| (node.name !== "Questionnaire" && node.name !== "Decision"))
);
}
return index < 0 ? undefined : { index, digest: digests(target)[index]! };
}

export function headingAllowed(target: Document): boolean {
let blocks = bodyBlocks(target);
let placeholder = target.titleKey === undefined
Expand Down
Loading
Loading