This commit is contained in:
Eugene Eisenstein 2026-09-03 20:49:00 +00:00 committed by GitHub
commit dc5b702551
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 366 additions and 14 deletions

View File

@ -0,0 +1,147 @@
/**
* Evidence Unit Tests
*
* Evidence rides on the terminal chunk of a dialectic stream, which the
* stream reader would otherwise discard. These tests drive mocked SSE bodies
* so they cover that without a server.
*/
import { describe, test, expect } from 'bun:test'
import { createDialecticStream } from '../src/http/streaming'
import type { Evidence } from '../src/types/api'
const EVIDENCE: Evidence = {
conclusions: [
{
id: 'doc-sentinel',
level: 'deductive',
content: 'User drinks coffee in the morning',
created_at: '2026-01-01T00:00:00Z',
session_id: 'session-1',
source_ids: ['doc-a', 'doc-b'],
},
],
messages: [
{
id: 'msg-sentinel',
session_id: 'session-1',
peer_id: 'alice',
created_at: '2026-01-01T00:00:00Z',
},
],
tool_calls: [{ tool_name: 'search_memory', tool_input: { query: 'coffee' } }],
reasoning_trace_id: null,
}
function mockSSEResponse(lines: string[]): Response {
const encoder = new TextEncoder()
let index = 0
const stream = new ReadableStream({
pull(controller) {
if (index < lines.length) {
controller.enqueue(encoder.encode(lines[index]))
index++
} else {
controller.close()
}
},
})
return new Response(stream, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
function contentFrame(content: string): string {
return `data: ${JSON.stringify({ delta: { content }, done: false })}\n\n`
}
function doneFrame(evidence?: Evidence): string {
return `data: ${JSON.stringify({ done: true, ...(evidence && { evidence })})}\n\n`
}
describe('streamed evidence', () => {
test('is captured off the terminal chunk', async () => {
const stream = createDialecticStream(
mockSSEResponse([
contentFrame('The user '),
contentFrame('drinks coffee.'),
doneFrame(EVIDENCE),
])
)
const chunks: string[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks.join('')).toBe('The user drinks coffee.')
expect(stream.evidence).toEqual(EVIDENCE)
})
test('survives being split across network chunks', async () => {
const frame = doneFrame(EVIDENCE)
const midpoint = Math.floor(frame.length / 2)
const stream = createDialecticStream(
mockSSEResponse([
contentFrame('Answer.'),
frame.slice(0, midpoint),
frame.slice(midpoint),
])
)
await stream.getFinalResponse()
expect(stream.evidence?.conclusions[0]?.id).toBe('doc-sentinel')
})
test('is null until the stream has been consumed', async () => {
const stream = createDialecticStream(
mockSSEResponse([
contentFrame('Answer.'),
doneFrame(EVIDENCE),
])
)
// Evidence cannot be known before the answer is complete.
expect(stream.evidence).toBeNull()
await stream.getFinalResponse()
expect(stream.evidence).not.toBeNull()
})
test('is null when the request did not ask for it', async () => {
const stream = createDialecticStream(
mockSSEResponse([
contentFrame('Answer.'),
doneFrame(),
])
)
await stream.getFinalResponse()
expect(stream.evidence).toBeNull()
})
test('does not disturb the content chunks', async () => {
const withEvidence = createDialecticStream(
mockSSEResponse([
contentFrame('a'),
contentFrame('b'),
doneFrame(EVIDENCE),
])
)
const withoutEvidence = createDialecticStream(
mockSSEResponse([
contentFrame('a'),
contentFrame('b'),
doneFrame(),
])
)
expect(await withEvidence.toArray()).toEqual(
await withoutEvidence.toArray()
)
})
})

View File

@ -590,6 +590,37 @@ describe('Peer', () => {
expect(response === null || typeof response === 'string').toBe(true)
})
test('chat with includeEvidence returns answer and evidence', async () => {
const peer = await client.peer('chat-evidence-peer')
const session = await client.session('chat-evidence-session', {
metadata: {},
})
await session.addPeers([peer.id])
await session.addMessages([peer.message('I enjoy hiking')])
const result = await peer.chat('What does this user enjoy?', {
includeEvidence: true,
})
// The answer is wrapped rather than returned bare, and evidence is
// present even when the run read nothing -- empty is not absent.
expect(result).toHaveProperty('content')
expect(result).toHaveProperty('evidence')
expect(result.evidence).not.toBeNull()
expect(Array.isArray(result.evidence?.conclusions)).toBe(true)
expect(Array.isArray(result.evidence?.messages)).toBe(true)
expect(Array.isArray(result.evidence?.tool_calls)).toBe(true)
})
test('chat without includeEvidence still returns a bare answer', async () => {
const peer = await client.peer('chat-no-evidence-peer')
const response = await peer.chat('What does this user enjoy?')
expect(response === null || typeof response === 'string').toBe(true)
})
test('chat with session scope', async () => {
const peer = await client.peer('chat-session-peer')
const session = await client.session('chat-scoped-session', { metadata: {} })

View File

@ -10,6 +10,7 @@ import { Peer } from './peer'
import { Scope } from './scope'
import { Session } from './session'
import type {
ChatResponse,
MessageResponse,
PageResponse,
PeerResponse,
@ -998,13 +999,33 @@ export class Honcho {
* @param options.responseFormat - Optional JSON Schema (root type "object") the response
* must conform to. When provided, the response content is a
* JSON string matching this schema.
* @returns Promise resolving to the response string, or null if no relevant information
* @param options.includeEvidence - When true, resolves to a ChatResponse carrying the
* answer alongside what the dialectic read to produce it.
* Evidence is collated from the agent's own reads rather
* than reported by the model, so it is broader than a
* citation list.
* @returns Promise resolving to the response string, or null if no relevant information.
* With includeEvidence, a ChatResponse wrapping that content plus its evidence.
*
* @example
* ```typescript
* const response = await honcho.chat('What are common themes across all users?')
*
* const { content, evidence } = await honcho.chat('What are common themes?', {
* includeEvidence: true,
* })
* ```
*/
async chat(
query: string,
options: {
session?: string | Session
reasoningLevel?: ReasoningLevel
responseFormat?: Record<string, unknown>
scope?: string | string[]
includeEvidence: true
}
): Promise<ChatResponse<string>>
async chat(
query: string,
options?: {
@ -1012,8 +1033,19 @@ export class Honcho {
reasoningLevel?: ReasoningLevel
responseFormat?: Record<string, unknown>
scope?: string | string[]
includeEvidence?: false
}
): Promise<string | null> {
): Promise<string | null>
async chat(
query: string,
options?: {
session?: string | Session
reasoningLevel?: ReasoningLevel
responseFormat?: Record<string, unknown>
scope?: string | string[]
includeEvidence?: boolean
}
): Promise<ChatResponse<string> | string | null> {
const validatedQuery = SearchQuerySchema.parse(query)
const resolvedSessionId = options?.session
? resolveId(options.session)
@ -1026,11 +1058,13 @@ export class Honcho {
reasoning_level: options?.reasoningLevel,
response_format: options?.responseFormat,
scope: options?.scope,
include_evidence: options?.includeEvidence ? true : undefined,
})
if (!response.content) {
return null
const content = response.content || null
if (!options?.includeEvidence) {
return content
}
return response.content
return { content, evidence: response.evidence ?? null }
}
/**
@ -1048,6 +1082,10 @@ export class Honcho {
* @param options.responseFormat - Optional JSON Schema (root type "object") the response
* must conform to. When provided, the response content is a
* JSON string matching this schema.
* @param options.includeEvidence - When true, the returned stream's `evidence` is
* populated once it has been fully consumed. It cannot
* be known before then, so the server sends it on the
* terminal chunk.
* @returns Promise resolving to a DialecticStreamResponse that can be iterated over
*
* @example
@ -1065,6 +1103,7 @@ export class Honcho {
reasoningLevel?: ReasoningLevel
responseFormat?: Record<string, unknown>
scope?: string | string[]
includeEvidence?: boolean
}
): Promise<DialecticStreamResponse> {
const validatedQuery = SearchQuerySchema.parse(query)
@ -1078,6 +1117,7 @@ export class Honcho {
reasoning_level: options?.reasoningLevel,
response_format: options?.responseFormat,
scope: options?.scope,
include_evidence: options?.includeEvidence ? true : undefined,
})
return createDialecticStream(response)

View File

@ -1,3 +1,5 @@
import type { Evidence } from '../types/api'
/**
* Parse Server-Sent Events from a Response body.
*
@ -64,6 +66,8 @@ export interface DialecticStreamChunk {
delta: {
content?: string
}
/** Set only on the terminal chunk, and only when it was requested. */
evidence?: Evidence | null
}
/**
@ -76,9 +80,25 @@ export class DialecticStreamResponse implements AsyncIterable<string> {
private generator: AsyncGenerator<string, void, undefined>
private chunks: string[] = []
private consumed = false
private evidenceSource?: () => Evidence | null
constructor(generator: AsyncGenerator<string, void, undefined>) {
constructor(
generator: AsyncGenerator<string, void, undefined>,
evidenceSource?: () => Evidence | null
) {
this.generator = generator
this.evidenceSource = evidenceSource
}
/**
* What the answer was built from, once the stream has finished.
*
* The server can only know this after the answer is complete, so it arrives
* on the stream's terminal chunk. Reading it before the stream is fully
* consumed returns null, as does a request that did not ask for evidence.
*/
get evidence(): Evidence | null {
return this.evidenceSource?.() ?? null
}
/**
@ -131,9 +151,13 @@ export class DialecticStreamResponse implements AsyncIterable<string> {
export function createDialecticStream(
response: Response
): DialecticStreamResponse {
// Captured from the terminal chunk, which is otherwise discarded.
let evidence: Evidence | null = null
async function* streamContent(): AsyncGenerator<string, void, undefined> {
for await (const chunk of parseSSE<DialecticStreamChunk>(response)) {
if (chunk.done) {
evidence = chunk.evidence ?? null
return
}
const content = chunk.delta?.content
@ -143,5 +167,5 @@ export function createDialecticStream(
}
}
return new DialecticStreamResponse(streamContent())
return new DialecticStreamResponse(streamContent(), () => evidence)
}

View File

@ -51,9 +51,14 @@ export {
// API types (snake_case, for advanced usage)
export type {
ChatResponse,
ConclusionLevel,
ConclusionQueryParams,
ConclusionResponse,
Evidence,
EvidenceMessageRef,
EvidenceObservation,
EvidenceToolCall,
MessageResponse,
PageResponse,
PeerContextResponse,

View File

@ -13,6 +13,7 @@ import { Page } from './pagination'
import type { Scope } from './scope'
import { Session } from './session'
import type {
ChatResponse,
MessageResponse,
PageResponse,
PeerCardResponse,
@ -259,6 +260,7 @@ export class Peer {
filters?: Record<string, unknown>
reasoning_level?: string
response_format?: Record<string, unknown>
include_evidence?: boolean
}): Promise<PeerChatResponse> {
await this._ensureWorkspace()
return this._http.post<PeerChatResponse>(
@ -275,6 +277,7 @@ export class Peer {
filters?: Record<string, unknown>
reasoning_level?: string
response_format?: Record<string, unknown>
include_evidence?: boolean
}): Promise<Response> {
await this._ensureWorkspace()
return this._http.stream(
@ -422,8 +425,33 @@ export class Peer {
sessions?: (string | Session)[]
reasoningLevel?: string
responseFormat: ZodType<T>
includeEvidence: true
}
): Promise<ChatResponse<T>>
async chat<T>(
query: string,
options: {
target?: string | Peer
session?: string | Session
scope?: string | Scope | (string | Scope)[]
sessions?: (string | Session)[]
reasoningLevel?: string
responseFormat: ZodType<T>
includeEvidence?: false
}
): Promise<T | null>
async chat(
query: string,
options: {
target?: string | Peer
session?: string | Session
scope?: string | Scope | (string | Scope)[]
sessions?: (string | Session)[]
reasoningLevel?: string
responseFormat?: Record<string, unknown>
includeEvidence: true
}
): Promise<ChatResponse<string>>
async chat(
query: string,
options?: {
@ -433,6 +461,7 @@ export class Peer {
sessions?: (string | Session)[]
reasoningLevel?: string
responseFormat?: Record<string, unknown>
includeEvidence?: false
}
): Promise<string | null>
async chat<T>(
@ -444,8 +473,9 @@ export class Peer {
sessions?: (string | Session)[]
reasoningLevel?: string
responseFormat?: ZodType<T> | Record<string, unknown>
includeEvidence?: boolean
}
): Promise<T | string | null> {
): Promise<ChatResponse<T | string> | T | string | null> {
const targetId = options?.target
? typeof options.target === 'string'
? options.target
@ -465,6 +495,7 @@ export class Peer {
sessions: options?.sessions,
reasoningLevel: options?.reasoningLevel,
responseFormat: options?.responseFormat,
includeEvidence: options?.includeEvidence,
})
const zodSchema =
@ -480,14 +511,20 @@ export class Peer {
...scopeRecallFields(chatParams),
reasoning_level: chatParams.reasoningLevel,
response_format: Peer.toResponseFormatSchema(options?.responseFormat),
include_evidence: chatParams.includeEvidence ? true : undefined,
})
if (!response.content) {
return null
// An empty answer stays null either way, so evidence is still available
// for a run that found nothing to say.
const content: T | string | null = response.content
? zodSchema
? (zodSchema.parse(JSON.parse(response.content)) as T)
: response.content
: null
if (!chatParams.includeEvidence) {
return content
}
if (zodSchema) {
return zodSchema.parse(JSON.parse(response.content))
}
return response.content
return { content, evidence: response.evidence ?? null }
}
/**
@ -511,6 +548,10 @@ export class Peer {
* See {@link Peer.chat} for the depth caveat.
* @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium",
* "high", or "max". Defaults to "low" if not provided.
* @param options.includeEvidence - When true, the returned stream's `evidence` is
* populated once it has been fully consumed. Evidence
* cannot be known before the answer is complete, so the
* server sends it on the stream's terminal chunk.
* @returns Promise resolving to a DialecticStreamResponse that can be iterated over
*
* @example
@ -537,6 +578,7 @@ export class Peer {
sessions?: (string | Session)[]
reasoningLevel?: string
responseFormat?: ZodType | Record<string, unknown>
includeEvidence?: boolean
}
): Promise<DialecticStreamResponse> {
const targetId = options?.target
@ -558,6 +600,7 @@ export class Peer {
sessions: options?.sessions,
reasoningLevel: options?.reasoningLevel,
responseFormat: options?.responseFormat,
includeEvidence: options?.includeEvidence,
})
const response = await this._chatStream({
@ -567,6 +610,7 @@ export class Peer {
...scopeRecallFields(chatParams),
reasoning_level: chatParams.reasoningLevel,
response_format: Peer.toResponseFormatSchema(options?.responseFormat),
include_evidence: chatParams.includeEvidence ? true : undefined,
})
return createDialecticStream(response)

View File

@ -75,10 +75,57 @@ export interface PeerChatParams {
target?: string
reasoning_level?: 'minimal' | 'low' | 'medium' | 'high' | 'max'
response_format?: Record<string, unknown>
include_evidence?: boolean
}
/** A conclusion the dialectic read while answering. */
export interface EvidenceObservation {
id: string
level: 'explicit' | 'deductive' | 'inductive' | 'contradiction'
content: string
created_at: string
session_id: string | null
/** Conclusions this one was derived from; empty for explicit conclusions. */
source_ids: string[]
}
/**
* A message the dialectic read while answering.
*
* Identity and provenance only no content. Fetch the message by `id` when
* you need its text; evidence is for auditing what was read, not for reading
* messages in bulk.
*/
export interface EvidenceMessageRef {
id: string
session_id: string
peer_id: string
created_at: string
}
/** A tool the dialectic invoked while answering. */
export interface EvidenceToolCall {
tool_name: string
tool_input: Record<string, unknown>
}
/**
* What the dialectic read and did while answering.
*
* Collated from what the agent accessed rather than reported by the model, so
* it over-reports: a listed conclusion was read, which is not proof the answer
* leaned on it. `toolCalls` omits results and failed calls.
*/
export interface Evidence {
conclusions: EvidenceObservation[]
messages: EvidenceMessageRef[]
tool_calls: EvidenceToolCall[]
reasoning_trace_id: string | null
}
export interface PeerChatResponse {
content: string | null
evidence?: Evidence | null
}
export interface WorkspaceChatParams {
@ -88,10 +135,23 @@ export interface WorkspaceChatParams {
reasoning_level?: 'minimal' | 'low' | 'medium' | 'high' | 'max'
response_format?: Record<string, unknown>
scope?: string | string[]
include_evidence?: boolean
}
export interface WorkspaceChatResponse {
content: string | null
evidence?: Evidence | null
}
/**
* An answer together with what it was built from.
*
* Returned by `chat` when `includeEvidence` is set; without it, `chat` returns
* the answer on its own.
*/
export interface ChatResponse<TContent = string> {
content: TContent | null
evidence: Evidence | null
}
export interface PeerRepresentationParams {

View File

@ -514,6 +514,7 @@ export const ChatQuerySchema = z
responseFormat: z
.union([z.instanceof(z.ZodType), z.record(z.string(), z.unknown())])
.optional(),
includeEvidence: z.boolean().optional(),
})
.strict()
.superRefine(scopeExclusivityIssues)