mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 17:39:39 +02:00
Document Pipeline (Team A): - LibrarianService: document storage with filesystem backend, metadata persistence, child document hierarchy, collection management - ChunkingService: recursive character text splitter with configurable chunk size/overlap, FlowProcessor pattern - KnowledgeExtractService: combined relationship + definition extraction using prompt service and LLM, emits RDF triples and entity contexts - KnowledgeCoreService: knowledge core CRUD with streaming export and flow-based loading ReAct Agent (Team B): - StreamingReActParser: state machine for parsing LLM output into Thought/Action/ActionInput/FinalAnswer sections - Three MVP tools: KnowledgeQuery (GraphRAG), DocumentQuery (DocRAG), TriplesQuery with RequestResponse clients - AgentService FlowProcessor with ReAct loop, tool execution, and streaming chunk responses (thought/observation/answer) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
130 lines
3.7 KiB
TypeScript
130 lines
3.7 KiB
TypeScript
/**
|
|
* Streaming ReAct parser -- state machine that processes LLM output one chunk at a time.
|
|
*
|
|
* Detects these markers in the LLM output:
|
|
* - "Thought:" -> emit thought content
|
|
* - "Action:" -> emit action name (tool name)
|
|
* - "Action Input:" -> emit action input (JSON args)
|
|
* - "Final Answer:" -> emit final answer content
|
|
*
|
|
* Handles markers split across chunks by buffering lines.
|
|
*/
|
|
|
|
import type { ReActState } from "./types.js";
|
|
|
|
const MARKERS = [
|
|
{ prefix: "Thought:", state: "thought" as ReActState },
|
|
{ prefix: "Action Input:", state: "action_input" as ReActState },
|
|
{ prefix: "Action:", state: "action" as ReActState },
|
|
{ prefix: "Final Answer:", state: "final_answer" as ReActState },
|
|
];
|
|
|
|
// Longest marker prefix for partial-match detection
|
|
const MAX_MARKER_LEN = Math.max(...MARKERS.map((m) => m.prefix.length));
|
|
|
|
export class StreamingReActParser {
|
|
private state: ReActState = "initial";
|
|
private buffer = "";
|
|
|
|
constructor(
|
|
private onThought: (text: string) => void,
|
|
private onAction: (name: string) => void,
|
|
private onActionInput: (input: string) => void,
|
|
private onFinalAnswer: (text: string) => void,
|
|
) {}
|
|
|
|
/**
|
|
* Feed a chunk of LLM output text into the parser.
|
|
* Accumulates in a buffer and processes complete lines.
|
|
*/
|
|
feed(text: string): void {
|
|
this.buffer += text;
|
|
this.processBuffer(false);
|
|
}
|
|
|
|
/**
|
|
* Flush any remaining buffered content at the end of output.
|
|
*/
|
|
flush(): void {
|
|
this.processBuffer(true);
|
|
// Emit any remaining buffer content in the current state
|
|
if (this.buffer.trim().length > 0) {
|
|
this.emitContent(this.buffer);
|
|
this.buffer = "";
|
|
}
|
|
}
|
|
|
|
private processBuffer(isFinal: boolean): void {
|
|
// Process complete lines (terminated by newline)
|
|
while (true) {
|
|
const newlineIdx = this.buffer.indexOf("\n");
|
|
if (newlineIdx === -1) {
|
|
// No complete line yet.
|
|
// If not final, check for partial marker match at the end and wait.
|
|
if (!isFinal) {
|
|
// If the remaining buffer could be the start of a marker, wait for more input.
|
|
const trimmed = this.buffer.trimStart();
|
|
if (trimmed.length > 0 && trimmed.length < MAX_MARKER_LEN) {
|
|
const couldBeMarker = MARKERS.some((m) =>
|
|
m.prefix.startsWith(trimmed),
|
|
);
|
|
if (couldBeMarker) {
|
|
// Wait for more input before deciding
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
const line = this.buffer.slice(0, newlineIdx);
|
|
this.buffer = this.buffer.slice(newlineIdx + 1);
|
|
this.processLine(line);
|
|
}
|
|
}
|
|
|
|
private processLine(line: string): void {
|
|
const trimmed = line.trimStart();
|
|
|
|
// Check if this line starts a new section
|
|
for (const marker of MARKERS) {
|
|
if (trimmed.startsWith(marker.prefix)) {
|
|
const content = trimmed.slice(marker.prefix.length).trim();
|
|
this.state = marker.state;
|
|
this.emitContent(content);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Otherwise, this is continuation content for the current state
|
|
if (trimmed.length > 0) {
|
|
this.emitContent(trimmed);
|
|
}
|
|
}
|
|
|
|
private emitContent(content: string): void {
|
|
if (content.length === 0) return;
|
|
|
|
switch (this.state) {
|
|
case "thought":
|
|
this.onThought(content);
|
|
break;
|
|
case "action":
|
|
this.onAction(content);
|
|
break;
|
|
case "action_input":
|
|
this.onActionInput(content);
|
|
break;
|
|
case "final_answer":
|
|
this.onFinalAnswer(content);
|
|
break;
|
|
case "initial":
|
|
// Content before any marker -- treat as thought
|
|
this.state = "thought";
|
|
this.onThought(content);
|
|
break;
|
|
case "complete":
|
|
break;
|
|
}
|
|
}
|
|
}
|