Merge Claude learner-facing message output
This commit is contained in:
@@ -28,7 +28,7 @@ import type { GraphStore } from "../../core/ports.ts";
|
||||
import { emit } from "../../core/events.ts";
|
||||
import { settleGraphLayout } from "../../core/layout-settle.ts";
|
||||
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
|
||||
import type { LhMessage } from "../../core/native-messages.ts";
|
||||
import { failedOutputCallIds, type LhMessage } from "../../core/native-messages.ts";
|
||||
import { auditorGraphTools } from "../tools/graph.ts";
|
||||
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
|
||||
|
||||
@@ -124,16 +124,20 @@ export function transcriptView(record: ChatRecord): string {
|
||||
}
|
||||
|
||||
/** The extraction view under the teacher-prose ruling (owner, 2026-09-28):
|
||||
* ONLY the teacher agent's text blocks — no learner messages, no
|
||||
* thinking, no tool calls or results, no quiz blocks — each labeled with
|
||||
* ONLY learner-facing teacher prose (text or send_to_user) — no learner
|
||||
* messages, thinking, other tool calls/results, or quiz blocks — labeled with
|
||||
* its ACTUAL message id (owner, 2026-10-13): the view lines ARE
|
||||
* core/coverage.ts messageViewLines ("TEACHER [id] text"), the exact
|
||||
* lines the coverage fingerprints hash, so the extractor can anchor
|
||||
* every proposition to the passage it came from (the session submit's
|
||||
* required messageId). The shared markdown engine stays reserved for the
|
||||
* future user-facing export. */
|
||||
function proseView(messages: ReadonlyArray<LhMessage>, title?: string): string {
|
||||
const lines = messages.flatMap((message) => messageViewLines(message, message.id));
|
||||
function proseView(
|
||||
messages: ReadonlyArray<LhMessage>,
|
||||
title?: string,
|
||||
failedOutputCalls: ReadonlySet<string> = failedOutputCallIds(messages),
|
||||
): string {
|
||||
const lines = messages.flatMap((message) => messageViewLines(message, message.id, failedOutputCalls));
|
||||
const head = title && title.length > 0 ? `# ${title}\n\n` : "";
|
||||
return head + lines.join("\n\n");
|
||||
}
|
||||
@@ -1113,6 +1117,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
// stage runs (typed — the trigger skips, routes surface 503).
|
||||
refuseIfStagesUnready();
|
||||
const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId);
|
||||
const failedOutputCalls = failedOutputCallIds(record.messages);
|
||||
if (covered.uncovered.length === 0) {
|
||||
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
|
||||
return { nodes: 0, chains: 0 };
|
||||
@@ -1148,14 +1153,14 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
// fingerprints' messageViewLines ("TEACHER [id] text"), one
|
||||
// shared seam.
|
||||
const messages = batch.map((slot) => slot.message);
|
||||
const view = proseView(messages, record.title);
|
||||
const view = proseView(messages, record.title, failedOutputCalls);
|
||||
// The batch's teacher-prose ids — the ONLY anchors this
|
||||
// job's submit may name (validateSessionAnchors at the
|
||||
// submit seam refuses anything else: ids from other
|
||||
// batches — even real ones of this chat — other chats,
|
||||
// and non-prose messages).
|
||||
const batchIds = new Set(
|
||||
messages.filter((m) => messageViewLines(m, m.id).length > 0).map((m) => m.id),
|
||||
messages.filter((m) => messageViewLines(m, m.id, failedOutputCalls).length > 0).map((m) => m.id),
|
||||
);
|
||||
setPhase(jobKey, "queued", "chat", undefined, part);
|
||||
const findings = await withExtractSlot(jobKey, async () => {
|
||||
@@ -1188,7 +1193,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
const batchStats = mergeFindings(chatId, findings, auditResult, { chatStore, graphStore }, {
|
||||
foldedAt,
|
||||
coverage: batch
|
||||
.filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
|
||||
.filter((slot) => slot.tokens > 0)
|
||||
.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
|
||||
});
|
||||
// The layout ledger evolves with the merge (sync — the
|
||||
|
||||
+26
-12
@@ -15,10 +15,11 @@ import {
|
||||
type TeacherToolContext,
|
||||
} from "./tools.ts";
|
||||
import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
|
||||
import { healToolRecord } from "./tools/heal.ts";
|
||||
import { healToolRecord, withArgumentHeal } from "./tools/heal.ts";
|
||||
import { sendToUserTool } from "./tools/send-to-user.ts";
|
||||
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
||||
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
||||
import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts";
|
||||
import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts";
|
||||
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
|
||||
import { usageWasAdmitted } from "./subscription-request-cycle.ts";
|
||||
|
||||
@@ -145,22 +146,27 @@ export interface AgentBuildOptions {
|
||||
* belt-and-suspenders HALF for the mixed batch (a question + other calls
|
||||
* in one message) that terminate-only semantics would let run on — the
|
||||
* learner cannot answer while a turn is in flight (per-chat 409), so a
|
||||
* cleanly-posed question MUST always end the turn. maxTurns is gone
|
||||
* cleanly-posed question MUST always end the turn. Practice's problem pose
|
||||
* has the same wait-for-the-learner contract, including mixed output/problem
|
||||
* batches; failed poses must still retry. maxTurns is gone
|
||||
* (owner ruling 2026-09-25: no maximum-agent-turns net on the teaching
|
||||
* turn).
|
||||
*/
|
||||
function makeQuestionStopper() {
|
||||
function makeQuestionStopper(kind: AgentKind) {
|
||||
return (context: {
|
||||
message: { content: Array<{ type: string; id?: string; name?: string }> };
|
||||
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
|
||||
}): boolean => {
|
||||
const called = new Set(
|
||||
context.message.content
|
||||
.filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!))
|
||||
.filter((c) => c.type === "toolCall" && c.name !== undefined && (
|
||||
(QUESTION_TOOLS as readonly string[]).includes(c.name) ||
|
||||
(kind === "practice" && c.name === PRACTICE_PROBLEM_TOOL)
|
||||
))
|
||||
.map((c) => c.id!),
|
||||
);
|
||||
if (called.size === 0) return false;
|
||||
// Stop only when every question call of this turn resolved cleanly.
|
||||
// Stop only when every question/problem pose resolved cleanly.
|
||||
for (const result of context.toolResults) {
|
||||
if (called.has(result.toolCallId) && result.isError) return false;
|
||||
}
|
||||
@@ -174,7 +180,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
||||
// conversation-spec resolution as teaching); the cast keeps AgentKind
|
||||
// ("practice" is not a ModelRole) honest about that.
|
||||
const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher");
|
||||
const stopper = makeQuestionStopper();
|
||||
const stopper = makeQuestionStopper(kind);
|
||||
const systemPrompt = opts.promptSwitches
|
||||
? composeInstructions(kind, opts.promptSwitches)
|
||||
: composedInstructions[kind]!;
|
||||
@@ -190,7 +196,16 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
||||
systemPrompt,
|
||||
model: engine.model,
|
||||
thinkingLevel: engine.level,
|
||||
tools,
|
||||
// Conditional registry inclusion, like the graph-tool gates: when
|
||||
// closed, neither the tool nor its usage instructions reach the model.
|
||||
// Match Claude across providers (including OpenRouter), not the API
|
||||
// transport. Explicit test registries remain caller-owned.
|
||||
tools: [
|
||||
...tools,
|
||||
...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude")
|
||||
? [withArgumentHeal(sendToUserTool)]
|
||||
: []),
|
||||
],
|
||||
messages: opts.messages ?? [],
|
||||
},
|
||||
streamFn: engine.streamFn,
|
||||
@@ -312,10 +327,9 @@ export function bareQuestionTools(): Record<string, AgentTool<any>> {
|
||||
* from turn 1 of a kind:"practice" session — no prober→teacher split,
|
||||
* no plan tools, no question tools. Its ENTIRE registry is
|
||||
* problem + conclude (tools/practice.ts); the doctrine prompt
|
||||
* (prompts/practice/) carries the method. The question-stop rule does
|
||||
* not apply (problem rides terminate: true natively —
|
||||
* QUESTION_TOOLS does not include it), so baseAgentOptions's stopper is
|
||||
* a no-op here; it stays wired for uniformity.
|
||||
* (prompts/practice/) carries the method. A successful problem pose ends
|
||||
* the run, natively for a problem-only batch and via the shared stop hook
|
||||
* for a mixed batch (for example, send_to_user + problem).
|
||||
*/
|
||||
export function createPracticeAgent(ctx: PracticeToolContext, opts: AgentBuildOptions = {}) {
|
||||
const tools = opts.tools ?? practiceTools(ctx);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { SEND_TO_USER_TOOL } from "../../core/native-messages.ts";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
|
||||
const sendToUserInput = Type.Object({
|
||||
message: Type.String({
|
||||
minLength: 1,
|
||||
pattern: "\\S",
|
||||
description: "The complete content to display to the learner. Markdown and LaTeX are supported.",
|
||||
}),
|
||||
});
|
||||
|
||||
/** Claude's documented verbatim mid-turn output channel. Availability is
|
||||
* model-gated at the shared conversation-agent construction boundary;
|
||||
* the native call remains in history, and the view renders its message. */
|
||||
export const sendToUserTool: AgentTool<typeof sendToUserInput> = {
|
||||
name: SEND_TO_USER_TOOL,
|
||||
label: SEND_TO_USER_TOOL,
|
||||
description:
|
||||
"## REQUIRED OUTPUT CHANNEL — ALWAYS USE `send_to_user`\n\n" +
|
||||
"**ALL learner-facing messages MUST be sent using `send_to_user`.** This includes explanations, teaching, " +
|
||||
"feedback, corrections, hints, introductions, progress messages, and final responses. " +
|
||||
"There is no exception for short messages or the end of a turn.\n\n" +
|
||||
"Ordinary response text may be converted into summarized thinking instead of reaching the learner. " +
|
||||
"**Never rely on ordinary response text to communicate with the learner. Thinking is not delivery.**\n\n" +
|
||||
"Put the complete message in `message`. Markdown and LaTeX are supported. " +
|
||||
"Do not duplicate it in ordinary response text.\n\n" +
|
||||
"Question content still belongs in the appropriate question tool; send any accompanying teaching or " +
|
||||
"commentary through `send_to_user` before calling that tool.\n\n" +
|
||||
"This tool does not pause or end your turn. Continue with other tools when needed. " +
|
||||
"After delivering your final message, end the turn without additional ordinary response text.",
|
||||
parameters: sendToUserInput,
|
||||
executionMode: "sequential",
|
||||
execute: serverExecute(SEND_TO_USER_TOOL, sendToUserInput, () => "Message received for display."),
|
||||
};
|
||||
Reference in New Issue
Block a user