@yeaft/webchat-agent 1.0.640 → 1.0.642
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/connection/message-router.js +4 -1
- package/index.js +1 -1
- package/local-runtime/server/handlers/agent-output.js +7 -5
- package/local-runtime/server/handlers/client-conversation.js +47 -0
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +158 -119
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +272 -24
- package/yeaft/conversation/session-history.js +336 -0
- package/yeaft/engine.js +88 -573
- package/yeaft/history-window.js +132 -5
- package/yeaft/routing/router.js +4 -0
- package/yeaft/sessions/index.js +1 -0
- package/yeaft/sessions/session-crud.js +68 -3
- package/yeaft/tools/agent.js +4 -1
- package/yeaft/web-bridge.js +255 -54
package/yeaft/history-window.js
CHANGED
|
@@ -565,6 +565,130 @@ function truncateToolResultsForModel(messages, options = {}) {
|
|
|
565
565
|
});
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
+
const TOOL_COMPACT_MARKER = '\n[tool result compacted for context budget; middle omitted]\n';
|
|
569
|
+
const MIN_TOOL_SAMPLE_BYTES = 64;
|
|
570
|
+
const TOOL_ERROR_FIELDS = new Set(['error', 'code', 'errorEffect', 'failureType', 'status']);
|
|
571
|
+
const BINARY_PART_TYPES = new Set(['image', 'input_image', 'document', 'input_file']);
|
|
572
|
+
|
|
573
|
+
function sampleToolText(text, retainedBytes) {
|
|
574
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
575
|
+
if (bytes.length <= retainedBytes + Buffer.byteLength(TOOL_COMPACT_MARKER)) return text;
|
|
576
|
+
let headEnd = Math.floor(retainedBytes / 2);
|
|
577
|
+
let tailStart = bytes.length - (retainedBytes - headEnd);
|
|
578
|
+
// UTF-8 boundaries also keep CJK and astral characters intact. The marker is
|
|
579
|
+
// charged together with both samples by estimateContentTokens below.
|
|
580
|
+
while (headEnd > 0 && (bytes[headEnd] & 0xc0) === 0x80) headEnd -= 1;
|
|
581
|
+
while (tailStart < bytes.length && (bytes[tailStart] & 0xc0) === 0x80) tailStart += 1;
|
|
582
|
+
return bytes.subarray(0, headEnd).toString('utf8') + TOOL_COMPACT_MARKER
|
|
583
|
+
+ bytes.subarray(tailStart).toString('utf8');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function sampleToolJson(value, retainedBytes) {
|
|
587
|
+
if (typeof value === 'string') return sampleToolText(value, retainedBytes);
|
|
588
|
+
if (!value || typeof value !== 'object' || BINARY_PART_TYPES.has(value.type)) return value;
|
|
589
|
+
if (Array.isArray(value)) return value.map(item => sampleToolJson(item, retainedBytes));
|
|
590
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
591
|
+
key, TOOL_ERROR_FIELDS.has(key) ? item : sampleToolJson(item, retainedBytes),
|
|
592
|
+
]));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function toolTextSampler(text) {
|
|
596
|
+
// Keep structured output parseable, including JSON inside text content parts
|
|
597
|
+
// and JSON string scalars. Only string leaves are sampled; flags, exit codes
|
|
598
|
+
// and Bash error markers stay whole. Unsafe numeric round trips and deeply
|
|
599
|
+
// nested structures stay intact for the existing protocol-unit budget fitter.
|
|
600
|
+
if (/^\s*[\[{"]/.test(text)) {
|
|
601
|
+
try {
|
|
602
|
+
const value = JSON.parse(text, (_key, item, context) => {
|
|
603
|
+
if (typeof item === 'number' && context?.source !== JSON.stringify(item)) {
|
|
604
|
+
throw new RangeError('JSON numeric representation would change');
|
|
605
|
+
}
|
|
606
|
+
return item;
|
|
607
|
+
});
|
|
608
|
+
const pending = [{ value, depth: 0 }];
|
|
609
|
+
while (pending.length > 0) {
|
|
610
|
+
const next = pending.pop();
|
|
611
|
+
if (!next.value || typeof next.value !== 'object') continue;
|
|
612
|
+
if (next.depth >= 64) return () => text;
|
|
613
|
+
for (const item of Object.values(next.value)) pending.push({ value: item, depth: next.depth + 1 });
|
|
614
|
+
}
|
|
615
|
+
return bytes => JSON.stringify(sampleToolJson(value, bytes));
|
|
616
|
+
} catch (error) {
|
|
617
|
+
// A valid but unsafe/deep JSON value must not crash sampling or silently
|
|
618
|
+
// change facts. Only invalid JSON falls through to ordinary text sampling.
|
|
619
|
+
if (!(error instanceof SyntaxError)) return () => text;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return bytes => sampleToolText(text, bytes);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function toolContentSampler(content) {
|
|
626
|
+
// Content parts keep their ordering, metadata and binary payloads. In
|
|
627
|
+
// particular, do not collapse mixed text/image/document output into JSON.
|
|
628
|
+
if (Array.isArray(content) && content.some(part => part && typeof part === 'object'
|
|
629
|
+
&& (['text', 'input_text', 'output_text'].includes(part.type) || BINARY_PART_TYPES.has(part.type)))) {
|
|
630
|
+
const samplers = content.map(part => {
|
|
631
|
+
if (typeof part === 'string') return toolTextSampler(part);
|
|
632
|
+
if (part && ['text', 'input_text', 'output_text'].includes(part.type)
|
|
633
|
+
&& typeof part.text === 'string') {
|
|
634
|
+
const sample = toolTextSampler(part.text);
|
|
635
|
+
return bytes => ({ ...part, text: sample(bytes) });
|
|
636
|
+
}
|
|
637
|
+
return () => part;
|
|
638
|
+
});
|
|
639
|
+
return bytes => samplers.map(sample => sample(bytes));
|
|
640
|
+
}
|
|
641
|
+
return toolTextSampler(serializeJsonValue(content));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function compactToolContentToBudget(content, tokenBudget) {
|
|
645
|
+
const sample = toolContentSampler(content);
|
|
646
|
+
const minimum = sample(MIN_TOOL_SAMPLE_BYTES);
|
|
647
|
+
if (estimateContentTokens(minimum) > tokenBudget) return minimum;
|
|
648
|
+
// Search measured content cost, not tokenBudget * chars/bytes. JSON escaping,
|
|
649
|
+
// CJK and multipart framing otherwise make small budgets easy to overshoot.
|
|
650
|
+
let low = MIN_TOOL_SAMPLE_BYTES;
|
|
651
|
+
let high = Math.max(low, Buffer.byteLength(serializeJsonValue(content), 'utf8'));
|
|
652
|
+
let fitted = minimum;
|
|
653
|
+
while (low < high) {
|
|
654
|
+
const mid = Math.ceil((low + high) / 2);
|
|
655
|
+
const candidate = sample(mid);
|
|
656
|
+
if (estimateContentTokens(candidate) <= tokenBudget) {
|
|
657
|
+
low = mid;
|
|
658
|
+
fitted = candidate;
|
|
659
|
+
} else {
|
|
660
|
+
high = mid - 1;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return fitted;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function compactCurrentToolResults(messages, messageBudget) {
|
|
667
|
+
const out = messages.slice();
|
|
668
|
+
let excess = estimateMessagesTokens(out) - messageBudget;
|
|
669
|
+
let compactedToolResults = 0;
|
|
670
|
+
let compactedToolResultTokensSaved = 0;
|
|
671
|
+
// Protect the complete newest tool batch, not merely its last result. All
|
|
672
|
+
// parallel results are evidence for the next assistant continuation.
|
|
673
|
+
let latestEnd = out.length - 1;
|
|
674
|
+
while (latestEnd >= 0 && out[latestEnd]?.role !== 'tool') latestEnd -= 1;
|
|
675
|
+
let latestStart = latestEnd;
|
|
676
|
+
while (latestStart > 0 && out[latestStart - 1]?.role === 'tool') latestStart -= 1;
|
|
677
|
+
for (let index = 0; index < latestStart && excess > 0; index += 1) {
|
|
678
|
+
const message = out[index];
|
|
679
|
+
if (message?.role !== 'tool') continue;
|
|
680
|
+
const before = estimateContentTokens(message.content);
|
|
681
|
+
const content = compactToolContentToBudget(message.content, Math.max(0, before - excess));
|
|
682
|
+
const saved = before - estimateContentTokens(content);
|
|
683
|
+
if (saved <= 0) continue;
|
|
684
|
+
out[index] = { ...message, content };
|
|
685
|
+
excess -= saved;
|
|
686
|
+
compactedToolResults += 1;
|
|
687
|
+
compactedToolResultTokensSaved += saved;
|
|
688
|
+
}
|
|
689
|
+
return { messages: out, compactedToolResults, compactedToolResultTokensSaved };
|
|
690
|
+
}
|
|
691
|
+
|
|
568
692
|
const HISTORY_SOURCE_INDEX = Symbol('historySourceIndex');
|
|
569
693
|
|
|
570
694
|
function withHistorySourceIndexes(messages, offset = 0) {
|
|
@@ -1139,8 +1263,9 @@ export function buildHistoryBuckets(snapshot, options = {}) {
|
|
|
1139
1263
|
* Fit one provider-request copy to the actual model window. The caller tells
|
|
1140
1264
|
* us where current-turn rows begin; only the prefix is subject to the history
|
|
1141
1265
|
* budget. If the complete request is still too large, old history disappears
|
|
1142
|
-
* first
|
|
1143
|
-
*
|
|
1266
|
+
* first. Under current-turn pressure, sample old tool-result text before
|
|
1267
|
+
* evicting disposable protocol units; the latest tool batch stays lossless
|
|
1268
|
+
* until the final budget fallback. The source and transcript are never mutated.
|
|
1144
1269
|
*
|
|
1145
1270
|
* @param {Array<object>} messages
|
|
1146
1271
|
* @param {{ contextWindow:number, systemTokens?:number, toolSchemaTokens?:number,
|
|
@@ -1167,9 +1292,9 @@ export function fitProviderRequestToContext(messages, options = {}) {
|
|
|
1167
1292
|
const historySource = source.slice(0, split);
|
|
1168
1293
|
const currentSource = source.slice(split);
|
|
1169
1294
|
|
|
1170
|
-
let current = pairSanitize(
|
|
1171
|
-
|
|
1172
|
-
|
|
1295
|
+
let current = pairSanitize(currentSource.map(message => ({ ...message })));
|
|
1296
|
+
const compact = compactCurrentToolResults(current, messageBudget);
|
|
1297
|
+
current = compact.messages;
|
|
1173
1298
|
if (estimateMessagesTokens(current) > messageBudget || current.length > messageCap) {
|
|
1174
1299
|
const fitted = [];
|
|
1175
1300
|
if (current.length > 0 && messageBudget >= 2 && messageCap > 0) {
|
|
@@ -1222,6 +1347,8 @@ export function fitProviderRequestToContext(messages, options = {}) {
|
|
|
1222
1347
|
currentMessagesAfter: current.length,
|
|
1223
1348
|
droppedHistoryMessages: historySource.length - history.length,
|
|
1224
1349
|
droppedCurrentMessages: currentSource.length - current.length,
|
|
1350
|
+
compactedToolResults: compact.compactedToolResults,
|
|
1351
|
+
compactedToolResultTokensSaved: compact.compactedToolResultTokensSaved,
|
|
1225
1352
|
},
|
|
1226
1353
|
};
|
|
1227
1354
|
}
|
package/yeaft/routing/router.js
CHANGED
|
@@ -180,6 +180,10 @@ export function createRouter(deps = {}) {
|
|
|
180
180
|
role: 'assistant', // VP-authored — persists as assistant turn
|
|
181
181
|
text,
|
|
182
182
|
taskId: args.taskId ?? null,
|
|
183
|
+
// Runtime provenance follows the human request through handoffs; the
|
|
184
|
+
// coordinator forwards underscore fields without persisting them.
|
|
185
|
+
_causalRootId: args.inboundEnvelope?._causalRootId
|
|
186
|
+
|| args.inboundEnvelope?._persistedUserMessage?.id || null,
|
|
183
187
|
// route_forward is already visible as the source VP's tool action.
|
|
184
188
|
// Persist the synthetic handoff for audit/dispatch, but keep it out
|
|
185
189
|
// of UI replay and future visible history so it doesn't render as a
|
package/yeaft/sessions/index.js
CHANGED
|
@@ -580,6 +580,14 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
|
580
580
|
* Create an independent Session from an existing Session's durable state.
|
|
581
581
|
* The Server inherits Project membership when it receives the copy result.
|
|
582
582
|
* Server-owned assets retain their original ownership; message references are preserved.
|
|
583
|
+
* @param {string} yeaftDir Agent-owned data root
|
|
584
|
+
* @param {string} sourceSessionId Existing Session
|
|
585
|
+
* @param {{name?:string, throughBoundary?:object, throughTurnId?:string, throughMessageId?:string|null, beforeMessageId?:string}} [options]
|
|
586
|
+
* throughBoundary forwards the opaque raw-prefix token returned synchronously by
|
|
587
|
+
* ConversationStore.captureSessionCopyBoundary. A requested throughTurnId must
|
|
588
|
+
* end within that prefix or fail closed (never truncate an active turn).
|
|
589
|
+
* Legacy throughMessageId/null and beforeMessageId fences remain supported.
|
|
590
|
+
* @returns {object} New Session metadata with copiedMessageCount
|
|
583
591
|
*/
|
|
584
592
|
export function copySession(yeaftDir, sourceSessionId, options = {}) {
|
|
585
593
|
const sourceYeaftDir = resolveSessionYeaftDir(yeaftDir, sourceSessionId);
|
|
@@ -618,9 +626,10 @@ export function copySession(yeaftDir, sourceSessionId, options = {}) {
|
|
|
618
626
|
}
|
|
619
627
|
|
|
620
628
|
const transcript = new ConversationStore(sourceYeaftDir);
|
|
621
|
-
const transcriptOptions =
|
|
622
|
-
|
|
623
|
-
|
|
629
|
+
const transcriptOptions = {};
|
|
630
|
+
for (const key of ['throughBoundary', 'throughTurnId', 'throughMessageId', 'beforeMessageId']) {
|
|
631
|
+
if (Object.prototype.hasOwnProperty.call(options, key)) transcriptOptions[key] = options[key];
|
|
632
|
+
}
|
|
624
633
|
const { copiedCount } = transcript.copySession(sourceSessionId, copied.id, transcriptOptions);
|
|
625
634
|
return { ...requireSessionMeta(sourceYeaftDir, copied.id), copiedMessageCount: copiedCount };
|
|
626
635
|
} catch (error) {
|
|
@@ -633,6 +642,62 @@ export function copySession(yeaftDir, sourceSessionId, options = {}) {
|
|
|
633
642
|
}
|
|
634
643
|
}
|
|
635
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Create a NEW Session from two idle Sessions in this Agent data root.
|
|
647
|
+
* Source config, announcement, default VP and workDir win; roster is the
|
|
648
|
+
* source-first union. Server inherits source Project membership separately.
|
|
649
|
+
* Neither source transcript nor metadata is modified. Any failure after target
|
|
650
|
+
* creation removes the new Session (including attachments and discovery entry).
|
|
651
|
+
* Ambiguous/partial shared requests fail closed with ambiguous_merge_history.
|
|
652
|
+
*
|
|
653
|
+
* @param {string} yeaftDir Agent-owned data root
|
|
654
|
+
* @param {string} sourceId Source of config/project/workdir semantics
|
|
655
|
+
* @param {string} otherId Other idle Session; must differ from sourceId
|
|
656
|
+
* @param {{name?:string, libDir?:string}} [options]
|
|
657
|
+
* @returns {object} New Session metadata, copiedMessageCount and sharedTurnCount
|
|
658
|
+
* @throws {SessionCrudError} invalid_merge_source, not_found, or ambiguous_merge_history
|
|
659
|
+
*/
|
|
660
|
+
export function mergeSessions(yeaftDir, sourceId, otherId, options = {}) {
|
|
661
|
+
if (typeof sourceId !== 'string' || typeof otherId !== 'string'
|
|
662
|
+
|| !/^[A-Za-z0-9_-]+$/.test(sourceId) || !/^[A-Za-z0-9_-]+$/.test(otherId)
|
|
663
|
+
|| sourceId === otherId) {
|
|
664
|
+
throw new SessionCrudError('invalid_merge_source', sourceId, 'Merge requires two distinct Sessions');
|
|
665
|
+
}
|
|
666
|
+
const sourceRoot = resolveSessionYeaftDir(yeaftDir, sourceId);
|
|
667
|
+
const otherRoot = resolveSessionYeaftDir(yeaftDir, otherId);
|
|
668
|
+
if (resolve(sourceRoot) !== resolve(otherRoot)) {
|
|
669
|
+
throw new SessionCrudError('invalid_merge_source', sourceId, 'Merge sources must belong to the same Agent data root');
|
|
670
|
+
}
|
|
671
|
+
const sourceMeta = requireSessionMeta(sourceRoot, sourceId);
|
|
672
|
+
const otherMeta = requireSessionMeta(otherRoot, otherId);
|
|
673
|
+
const sourceConfig = loadSessionConfig(sourceRoot, sourceId);
|
|
674
|
+
const roster = [...new Set([...(sourceMeta.roster || []), ...(otherMeta.roster || [])])];
|
|
675
|
+
const merged = createSessionFromSpec(sourceRoot, {
|
|
676
|
+
name: String(options.name || '').trim() || `${sourceMeta.name} merged`,
|
|
677
|
+
roster,
|
|
678
|
+
defaultVpId: sourceMeta.defaultVpId || roster[0] || null,
|
|
679
|
+
workDir: sourceMeta.workDir || '',
|
|
680
|
+
}, { ...options, preserveEmptyRoster: true });
|
|
681
|
+
try {
|
|
682
|
+
saveSessionConfig(sourceRoot, merged.id, sourceConfig);
|
|
683
|
+
const target = requireSession(sourceRoot, merged.id);
|
|
684
|
+
try {
|
|
685
|
+
const meta = { ...target.getMeta(), announcement: sourceMeta.announcement || '', metadataUpdatedAt: new Date().toISOString() };
|
|
686
|
+
target.saveMeta(meta);
|
|
687
|
+
addOrUpdateManifestSession(sourceRoot, meta, target.dir);
|
|
688
|
+
} finally { target.close(); }
|
|
689
|
+
const { copiedCount, sharedTurnCount } = new ConversationStore(sourceRoot)
|
|
690
|
+
.mergeSessions(sourceId, otherId, merged.id, options);
|
|
691
|
+
return { ...requireSessionMeta(sourceRoot, merged.id), copiedMessageCount: copiedCount, sharedTurnCount };
|
|
692
|
+
} catch (error) {
|
|
693
|
+
deleteSession(sourceRoot, merged.id, options);
|
|
694
|
+
if (error?.code === 'ambiguous_merge_history' || error?.code === 'invalid_merge_source') {
|
|
695
|
+
throw new SessionCrudError(error.code, sourceId, error.message);
|
|
696
|
+
}
|
|
697
|
+
throw error;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
636
701
|
function requireSessionMeta(yeaftDir, sessionId) {
|
|
637
702
|
const handle = requireSession(yeaftDir, sessionId);
|
|
638
703
|
try { return handle.getMeta(); } finally { handle.close(); }
|
package/yeaft/tools/agent.js
CHANGED
|
@@ -521,7 +521,10 @@ stops or finalizes work no longer needed.`,
|
|
|
521
521
|
title: spec.mission || spec.task || name,
|
|
522
522
|
resultDelivery: TASK_RESULT_DELIVERY.MODEL_REENTRY,
|
|
523
523
|
runtime: { subAgentId: agentId, name, cwd: agent.cwd },
|
|
524
|
-
source: {
|
|
524
|
+
source: {
|
|
525
|
+
threadId: callerScope.parentThreadId || ctx?.threadId || 'main',
|
|
526
|
+
...(ctx.causalRootId ? { causalRootId: ctx.causalRootId } : {}),
|
|
527
|
+
},
|
|
525
528
|
});
|
|
526
529
|
if (task?.id) {
|
|
527
530
|
agent.taskId = task.id;
|