Skip to content
Closed
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
7 changes: 4 additions & 3 deletions apps/hub/src/artifacts/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -950,8 +950,9 @@ export function createArtifactService(
.where(
and(
eq(approval.tenantId, actor.tenantId),
// An expired ask is one a coordinator upgrade withdrew; its successor asks again.
notInArray(approval.status, ["pending", "expired"]),
// An expired ask is one a coordinator upgrade withdrew; its successor asks again. A
// responded one was questions, whose answers are the person's turn.
notInArray(approval.status, ["pending", "expired", "responded"]),
),
)
.orderBy(approval.createdAt);
Expand All @@ -970,7 +971,7 @@ export function createArtifactService(
);
return {
approvals: rows.flatMap((row) =>
row.status === "pending"
row.status === "pending" || row.status === "responded"
? []
: [
{
Expand Down
1 change: 1 addition & 0 deletions apps/hub/src/config/project-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const APPROVAL: Record<string, string> = {
instruct_coder: "Approve the change for the app builder",
design_app: "Approve the design work",
install_packages: "Approve the packages the app builder asked for",
ask_questions: "Answer the coordinator's questions",
};

export const projectStatusWording = {
Expand Down
3 changes: 0 additions & 3 deletions apps/hub/src/config/questions.ts

This file was deleted.

2 changes: 1 addition & 1 deletion apps/hub/src/config/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const resumeMessage = (context: string | undefined, message: string) =>
export function approvalDecisionLine({
outcome,
message,
}: ApprovalDecision): string {
}: Exclude<ApprovalDecision, { outcome: "responded" }>): string {
const decided = outcome === "approved" ? "Approved" : "Rejected";
// A blank note is no note: the line ends at the decision, not at a dangling colon.
const note = message?.trim();
Expand Down
53 changes: 52 additions & 1 deletion apps/hub/src/mail/conversation-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import {
approvalDecisionLine,
withoutResumeContext,
} from "../config/resume.ts";
import {
ASK_QUESTIONS,
QuestionsResponse,
questionsResponseText,
} from "@corbits-builder/schemas/questions";
import { personTurnText } from "../config/specialist-ended.ts";
import { declinedWords } from "../config/decline.ts";
import { readCoordinatorMailThreads } from "./conversation-threads.ts";
Expand Down Expand Up @@ -79,7 +84,9 @@ function approvalText(
decisions: ApprovalDecisionTurns,
): string | undefined {
const decision = ApprovalDecision(parseJson(body));
if (decision instanceof type.errors) return undefined;
// Answers are the person's turn by the questions call's own result.
if (decision instanceof type.errors || decision.outcome === "responded")
return undefined;
const words = declinedWords(decision.message);
return decisions === "note"
? words
Expand All @@ -95,19 +102,63 @@ function personText(body: string): string | undefined {
return clippedTurnText(personTurnText(text));
}

const blocksOf = (content: unknown): Record<string, unknown>[] =>
Array.isArray(content) ? content.filter(isRecord) : [];

/** The questions calls an assistant turn makes, by id. */
const questionCalls = (content: unknown): string[] =>
blocksOf(content).flatMap((block) =>
block["type"] === "tool_call" &&
block["name"] === ASK_QUESTIONS &&
typeof block["id"] === "string"
? [block["id"]]
: [],
);

/** The person's response a turn returns to a questions call, as their words. */
const answersOf = (
content: unknown,
asked: ReadonlySet<string>,
): string | undefined => {
const result = blocksOf(content)
.flatMap((block) =>
block["type"] === "tool_result" &&
block["isError"] !== true &&
typeof block["callId"] === "string" &&
asked.has(block["callId"])
? [textFromContent(block["content"])]
: [],
)
.find((text) => text !== undefined);
if (result === undefined) return undefined;
const response = QuestionsResponse(parseJson(result));
return response instanceof type.errors
? result
: questionsResponseText(response);
};

// A missing or corrupt turn list is empty; it does not throw.
function mapMirrorConversationTurns(
turns: unknown,
decisions: ApprovalDecisionTurns,
): ConversationTextTurn[] {
if (!Array.isArray(turns)) return [];
const mapped: ConversationTextTurn[] = [];
const asked = new Set<string>();
for (const turn of turns) {
if (!isRecord(turn)) continue;
const role = mirrorRole(turn["role"]);
const at = turn["timestamp"];
if (role === undefined || typeof at !== "number" || !Number.isFinite(at))
continue;
if (role === "assistant")
for (const id of questionCalls(turn["content"])) asked.add(id);
const answers =
role === "inbound" ? answersOf(turn["content"], asked) : undefined;
if (answers !== undefined) {
mapped.push({ role: "person", text: clippedTurnText(answers), at });
continue;
}
const raw = textFromContent(turn["content"]);
if (raw === undefined) continue;
if (role === "assistant") {
Expand Down
14 changes: 11 additions & 3 deletions apps/hub/src/projects/coordinator-upgrade.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, desc, eq, inArray, ne } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
import { type } from "arktype";
import { createApprovalStore, type DB } from "@intx/db";
import {
Expand Down Expand Up @@ -183,12 +183,20 @@ export function createCoordinatorUpgrades(deps: {
return asked !== undefined;
}

// The coordinator's own undecided approval, which it waits on.
// The coordinator's own undecided approval, which it waits on. A call waiting for the person's
// response, such as open questions, is not one: the person may be answering it, and a successor
// would not know what was asked.
async function ownApproval(runId: string) {
const [asked] = await db
.select({ correlationId: approval.correlationId })
.from(approval)
.where(and(eq(approval.runId, runId), eq(approval.status, "pending")))
.where(
and(
eq(approval.runId, runId),
eq(approval.status, "pending"),
isNull(sql`${approval.toolDefinition}->>'respondable'`),
),
)
.limit(1);
return asked?.correlationId;
}
Expand Down
41 changes: 22 additions & 19 deletions apps/hub/src/projects/deploy-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from "@corbits-builder/schemas/artifact";
import { Preferences } from "@corbits-builder/schemas/preferences";
import {
ASK_QUESTIONS,
AskQuestions,
askQuestionsIssue,
} from "@corbits-builder/schemas/questions";
Expand All @@ -54,7 +55,6 @@ import {
designConceptAssignment,
designLookIdeas,
} from "../config/design.ts";
import { QUESTIONS_ASKED } from "../config/questions.ts";
import {
buildPackageAssignment,
formatPersonasCatalog,
Expand Down Expand Up @@ -259,9 +259,9 @@ export function createDeployTools(options?: {
inputSchema: { ...RequestApproval.toJsonSchema() },
},
{
name: "ask_questions",
name: ASK_QUESTIONS,
description:
"Put up to five questions to the person on one card shown below your reply; your reply introduces them without repeating them. id is a short slug unique within the call. kind single takes one option and multi any number. preselected holds the option values you recommend, at most one for single. allowOther lets the person answer in their own words. Their answers arrive as their next message.",
"Put up to five questions to the person on one card shown below your reply; your reply introduces them without repeating them. id is a short slug unique within the call. kind single takes one option and multi any number. preselected holds the option values you recommend, at most one for single. allowOther lets the person answer in their own words. The call waits for the person, and its result is their response as JSON: answers lists each question with the labels they chose, their own words included, or reply holds their own words when they answered in the conversation instead.",
inputSchema: { ...AskQuestions.toJsonSchema() },
},
{
Expand Down Expand Up @@ -313,7 +313,7 @@ export function createDeployTools(options?: {
requires: ["capabilities"],
definitions: [
{ name: "request_approval", approval: "ask" },
{ name: "ask_questions" },
{ name: ASK_QUESTIONS, approval: "respond" },
{ name: "deploy_writer" },
{ name: "review_artifact" },
{ name: "deploy_coder" },
Expand All @@ -323,22 +323,25 @@ export function createDeployTools(options?: {
],
factory: (env) => ({
definitions,
run: async (call, signal) => {
if (call.name === "ask_questions") {
try {
const issue = askQuestionsIssue(
validArguments(AskQuestions, call.arguments),
);
if (issue) throw new Refusal("invalid_questions: " + issue);
return { callId: call.id, content: QUESTIONS_ASKED };
} catch (error) {
return {
callId: call.id,
content: refusalText(error),
isError: true,
};
}
// Questions are checked before the call waits on the person, so a malformed set is refused at
// once instead of becoming a card nobody can answer.
check: async (call) => {
if (call.name !== ASK_QUESTIONS) return undefined;
try {
const issue = askQuestionsIssue(
validArguments(AskQuestions, call.arguments),
);
if (issue) throw new Refusal("invalid_questions: " + issue);
return undefined;
} catch (error) {
return {
callId: call.id,
content: refusalText(error),
isError: true,
};
}
},
run: async (call, signal) => {
if (!options) {
try {
const scope = await resolveWorkflowProjectScope(
Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/api/ask.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { type } from "arktype";
import { DesignConcept } from "@corbits-builder/schemas/artifact";
import {
ASK_QUESTIONS,
AskQuestions,
} from "@corbits-builder/schemas/questions";
import type { Wait } from "./types.ts";

const Names = type("string[]");
Expand All @@ -10,7 +14,13 @@ export function askOf(
args: Record<string, unknown>,
): Pick<
Wait,
"toolName" | "packages" | "concepts" | "instruction" | "subject" | "kinds"
| "toolName"
| "packages"
| "concepts"
| "instruction"
| "subject"
| "kinds"
| "questions"
> {
const packages = args["packages"];
const listed = Names(args["kinds"]);
Expand All @@ -21,12 +31,16 @@ export function askOf(
const concepts = DesignConcept.array()(args["concepts"]);
const designConcepts = concepts instanceof type.errors ? undefined : concepts;
const installs = Names(packages);
// Questions in the shape the card draws; the hub refuses any other before the call waits.
const questions =
toolName === ASK_QUESTIONS && AskQuestions.allows(args) ? args : undefined;
return {
...(toolName === undefined ? {} : { toolName }),
...(installs instanceof type.errors ? {} : { packages: installs }),
...(designConcepts === undefined ? {} : { concepts: designConcepts }),
...(typeof instruction === "string" ? { instruction } : {}),
...(typeof subject === "string" ? { subject } : {}),
...(listed instanceof type.errors ? {} : { kinds: listed }),
...(questions === undefined ? {} : { questions }),
};
}
25 changes: 23 additions & 2 deletions apps/web/src/api/decisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { wording } from "../config.ts";
import { productClient } from "./transport.ts";
import type { Wait } from "./types.ts";
import { askOf } from "./ask.ts";
import { ASK_QUESTIONS } from "@corbits-builder/schemas/questions";
import { isInputPark, readConversationEvents } from "./conversation.ts";
import { workspaceTenantId } from "./auth.ts";

Expand Down Expand Up @@ -94,8 +95,15 @@ export async function openDecisions(onlyProjectId?: string): Promise<Wait[]> {
const pending = approvals.filter(
(approval) => approval.status === "pending",
);
const projectWaits: Omit<Wait, "projectTitle">[] = pending.map((approval) =>
approvalWait(approval, record.projectId, record.tenantId),
// Questions are answered on their card, never approved, so any the card cannot draw are left
// out rather than shown as an approval the hub would refuse.
const projectWaits: Omit<Wait, "projectTitle">[] = pending.flatMap(
(approval) => {
const wait = approvalWait(approval, record.projectId, record.tenantId);
return wait.toolName === ASK_QUESTIONS && wait.questions === undefined
? []
: [wait];
},
);
// Only the latest input park is the person's move; earlier parks that a
// later SignalReceived released are not waiting on anything.
Expand All @@ -113,6 +121,19 @@ export async function openDecisions(onlyProjectId?: string): Promise<Wait[]> {
return waits;
}

/** Responds to an ask whose result is the person's own input; the waiting call returns `result`. */
export async function respondToApproval(input: {
tenantId: string;
approvalId: string;
result: string;
}): Promise<void> {
await productClient().respondToNativeApproval(
input.tenantId,
input.approvalId,
input.result,
);
}

export type DecisionChoice = "approve" | "reject";

/** Resolves one native approval; the hub binds nothing the person did not see in the park. */
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* no hub record, its type says so and it carries an empty value.
*/
import type { ProjectStage } from "@corbits-builder/schemas/project";
import type { AskQuestions } from "@corbits-builder/schemas/questions";

export type ApiErrorDetail = {
code: string;
Expand Down Expand Up @@ -58,6 +59,8 @@ export type Wait = {
packages?: readonly string[];
/** The design concepts a design_app approval starts. */
concepts?: readonly { readonly label: string; readonly direction: string }[];
/** The questions an ask_questions call waits on the person to answer. */
questions?: AskQuestions;
/**
* The coordinator's open input park: the person's move in the
* conversation. `parkProse` on the carrying event is the question to show;
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1044,11 +1044,9 @@ export const wording = {
notSent: "Your answers were not sent. Your choices are kept; try again.",
/** The settled card's heading, over each question and its answer. */
answered: "Answered",
/** The questions' answer when the person sends files without words while they are open. */
sentFiles: (names: readonly string[]) => `Sent ${names.join(", ")}`,
answeredInChat: "Answered in the conversation",
replaced: "Replaced by the questions that followed",
/** One list item of the person's reply per question: "- Who signs in? — Staff, Guests". */
answerLine: (prompt: string, answers: readonly string[]) =>
`- ${prompt} — ${answers.join(", ")}`,
},
archivedProject: {
title: "This project is archived",
Expand Down
Loading