iranti 0.3.21 → 0.3.23
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/dist/scripts/iranti-mcp.js +9 -4
- package/dist/src/api/server.js +30 -1
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/attendant/AttendantInstance.d.ts +30 -1
- package/dist/src/attendant/AttendantInstance.d.ts.map +1 -1
- package/dist/src/attendant/AttendantInstance.js +317 -14
- package/dist/src/attendant/AttendantInstance.js.map +1 -1
- package/dist/src/lib/check-bootstrap-state.d.ts +2 -0
- package/dist/src/lib/check-bootstrap-state.d.ts.map +1 -0
- package/dist/src/lib/check-bootstrap-state.js +17 -0
- package/dist/src/lib/check-bootstrap-state.js.map +1 -0
- package/dist/src/lib/providers/mock.d.ts +98 -0
- package/dist/src/lib/providers/mock.d.ts.map +1 -1
- package/dist/src/lib/providers/mock.js +219 -15
- package/dist/src/lib/providers/mock.js.map +1 -1
- package/dist/src/lib/test-copilot-bootstrap.d.ts +2 -0
- package/dist/src/lib/test-copilot-bootstrap.d.ts.map +1 -0
- package/dist/src/lib/test-copilot-bootstrap.js +139 -0
- package/dist/src/lib/test-copilot-bootstrap.js.map +1 -0
- package/dist/src/sdk/index.d.ts +6 -0
- package/dist/src/sdk/index.d.ts.map +1 -1
- package/dist/src/sdk/index.js +1 -0
- package/dist/src/sdk/index.js.map +1 -1
- package/package.json +7 -2
|
@@ -7,8 +7,10 @@ exports.extractRuleTriggers = extractRuleTriggers;
|
|
|
7
7
|
exports.matchesRuleTriggers = matchesRuleTriggers;
|
|
8
8
|
exports.formatMatchedUserRules = formatMatchedUserRules;
|
|
9
9
|
exports.extractFilePathEntityHints = extractFilePathEntityHints;
|
|
10
|
+
exports.derivePendingToolCallEntityHints = derivePendingToolCallEntityHints;
|
|
10
11
|
exports.readPersistedSessionState = readPersistedSessionState;
|
|
11
12
|
exports.summarizeSessionState = summarizeSessionState;
|
|
13
|
+
exports.injectedFactsAreTaskRelevant = injectedFactsAreTaskRelevant;
|
|
12
14
|
const crypto_1 = require("crypto");
|
|
13
15
|
const router_1 = require("../lib/router");
|
|
14
16
|
const staffEventRegistry_1 = require("../lib/staffEventRegistry");
|
|
@@ -59,6 +61,11 @@ const SESSION_INTERRUPTION_TTL_MS = 5 * 60 * 1000;
|
|
|
59
61
|
const PERSISTENCE_WARNING_THRESHOLD = 3;
|
|
60
62
|
const PERSISTENCE_NON_COMPLIANT_THRESHOLD = 5;
|
|
61
63
|
const ENTITY_DETECTION_WINDOW_CHARS = 1500;
|
|
64
|
+
// A1: mid-turn attends default to a smaller fact budget than pre-response.
|
|
65
|
+
// The agent is already deep in the task with a working memory frame; the point
|
|
66
|
+
// of a mid-turn attend is to surface 1-3 NEW facts on a topic shift, not to
|
|
67
|
+
// re-dump the whole briefing.
|
|
68
|
+
const MID_TURN_DEFAULT_MAX_FACTS = 3;
|
|
62
69
|
const MIN_ENTITY_CONFIDENCE = 0.75;
|
|
63
70
|
const MEMORY_DECISION_CONTEXT_WINDOW_CHARS = 2000;
|
|
64
71
|
const LEDGER_WORKING_MEMORY_PREFIX = 'system/session_ledger/recent_learning_';
|
|
@@ -446,6 +453,174 @@ function extractFilePathEntityHints(text, projectEntity) {
|
|
|
446
453
|
}
|
|
447
454
|
return hints;
|
|
448
455
|
}
|
|
456
|
+
// ─── A2: Pending Tool-Call Entity Hints ─────────────────────────────────────
|
|
457
|
+
//
|
|
458
|
+
// Derive entity hints from a structured tool call that the agent is about to
|
|
459
|
+
// make. This lets attend() preempt redundant read-only tool calls by surfacing
|
|
460
|
+
// stored facts keyed to the target of the tool call (a file, a URL, a query).
|
|
461
|
+
//
|
|
462
|
+
// We deliberately keep this a PURE function so it is trivially unit-testable
|
|
463
|
+
// and so the caller can exercise different tool shapes without spinning up an
|
|
464
|
+
// attendant. It returns entity hints in `entityType/entityId` form.
|
|
465
|
+
function normalizeWebToken(value, maxLen = 48) {
|
|
466
|
+
return value
|
|
467
|
+
.toLowerCase()
|
|
468
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
469
|
+
.replace(/^_+|_+$/g, '')
|
|
470
|
+
.slice(0, maxLen);
|
|
471
|
+
}
|
|
472
|
+
function deriveFileEntityFromPath(rawPath, projectId, seen, hints) {
|
|
473
|
+
const trimmed = rawPath.trim();
|
|
474
|
+
if (!trimmed)
|
|
475
|
+
return;
|
|
476
|
+
const basename = trimmed.replace(/\\/g, '/').split('/').pop() ?? '';
|
|
477
|
+
// Strip trailing args like "foo.ts:42" or "foo.ts,bar.ts"
|
|
478
|
+
const stripped = basename.split(/[,:()]/)[0] ?? basename;
|
|
479
|
+
const nameWithoutExt = stripped
|
|
480
|
+
.replace(/\.\w+$/, '')
|
|
481
|
+
.replace(/[^a-zA-Z0-9]/g, '_')
|
|
482
|
+
.toLowerCase();
|
|
483
|
+
if (!nameWithoutExt || nameWithoutExt.length < 2)
|
|
484
|
+
return;
|
|
485
|
+
const hint = `project/${projectId}/file/${nameWithoutExt}`;
|
|
486
|
+
if (!seen.has(hint)) {
|
|
487
|
+
seen.add(hint);
|
|
488
|
+
hints.push(hint);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function scanStringForPathEntities(text, projectId, seen, hints) {
|
|
492
|
+
if (!text)
|
|
493
|
+
return;
|
|
494
|
+
FILE_PATH_PATTERN.lastIndex = 0;
|
|
495
|
+
let match;
|
|
496
|
+
while ((match = FILE_PATH_PATTERN.exec(text)) !== null) {
|
|
497
|
+
deriveFileEntityFromPath(match[1], projectId, seen, hints);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function extractBasenameFromGlobPattern(pattern) {
|
|
501
|
+
// A glob like "src/**/*.ts" has no meaningful basename; one like
|
|
502
|
+
// "tests/attendant/run_mid_turn_attend_tests.ts" does. We only derive a
|
|
503
|
+
// file entity from the last segment if it looks like a literal filename.
|
|
504
|
+
const segments = pattern.replace(/\\/g, '/').split('/');
|
|
505
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
506
|
+
const seg = segments[i];
|
|
507
|
+
if (!seg || seg.includes('*') || seg.includes('?') || seg === '.' || seg === '..')
|
|
508
|
+
continue;
|
|
509
|
+
if (/\.\w+$/.test(seg))
|
|
510
|
+
return seg;
|
|
511
|
+
}
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Derive entity hints from a pending tool call. Pure, stateless, safe to call
|
|
516
|
+
* from tests. Callers should merge the result into `effectiveEntityHints`
|
|
517
|
+
* *after* text-derived hints so text signals still take precedence when a hint
|
|
518
|
+
* appears in both places.
|
|
519
|
+
*/
|
|
520
|
+
function derivePendingToolCallEntityHints(toolCall, projectEntity) {
|
|
521
|
+
if (!toolCall || !projectEntity)
|
|
522
|
+
return [];
|
|
523
|
+
const parsed = (0, entity_resolution_1.parseEntityString)(projectEntity);
|
|
524
|
+
const projectId = parsed.entityId;
|
|
525
|
+
if (!projectId)
|
|
526
|
+
return [];
|
|
527
|
+
const args = (toolCall.args ?? {});
|
|
528
|
+
const seen = new Set();
|
|
529
|
+
const hints = [];
|
|
530
|
+
switch (toolCall.name) {
|
|
531
|
+
case 'Read': {
|
|
532
|
+
const filePath = typeof args.file_path === 'string' ? args.file_path : '';
|
|
533
|
+
if (filePath) {
|
|
534
|
+
deriveFileEntityFromPath(filePath, projectId, seen, hints);
|
|
535
|
+
}
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
case 'Grep': {
|
|
539
|
+
// Grep has pattern + optional path + optional glob. Only path/glob
|
|
540
|
+
// can yield file-level entities; the regex pattern itself is
|
|
541
|
+
// content-level and gets surfaced via the existing text hints from
|
|
542
|
+
// latestMessage/currentContext.
|
|
543
|
+
if (typeof args.path === 'string') {
|
|
544
|
+
deriveFileEntityFromPath(args.path, projectId, seen, hints);
|
|
545
|
+
}
|
|
546
|
+
if (typeof args.glob === 'string') {
|
|
547
|
+
const base = extractBasenameFromGlobPattern(args.glob);
|
|
548
|
+
if (base)
|
|
549
|
+
deriveFileEntityFromPath(base, projectId, seen, hints);
|
|
550
|
+
}
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
case 'Glob': {
|
|
554
|
+
if (typeof args.pattern === 'string') {
|
|
555
|
+
const base = extractBasenameFromGlobPattern(args.pattern);
|
|
556
|
+
if (base)
|
|
557
|
+
deriveFileEntityFromPath(base, projectId, seen, hints);
|
|
558
|
+
// If the pattern has no literal basename, fall through to the
|
|
559
|
+
// project entity so stored project-level facts still surface.
|
|
560
|
+
}
|
|
561
|
+
if (typeof args.path === 'string') {
|
|
562
|
+
deriveFileEntityFromPath(args.path, projectId, seen, hints);
|
|
563
|
+
}
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
case 'Bash': {
|
|
567
|
+
// Scan the command string for embedded paths. This covers
|
|
568
|
+
// `cat src/foo.ts`, `rm ./dist/bar.js`, `node scripts/baz.ts`, etc.
|
|
569
|
+
const command = typeof args.command === 'string' ? args.command : '';
|
|
570
|
+
scanStringForPathEntities(command, projectId, seen, hints);
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
case 'WebSearch': {
|
|
574
|
+
const query = typeof args.query === 'string' ? args.query : '';
|
|
575
|
+
const token = normalizeWebToken(query);
|
|
576
|
+
if (token) {
|
|
577
|
+
const hint = `web/search_${token}`;
|
|
578
|
+
if (!seen.has(hint)) {
|
|
579
|
+
seen.add(hint);
|
|
580
|
+
hints.push(hint);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
case 'WebFetch': {
|
|
586
|
+
const url = typeof args.url === 'string' ? args.url : '';
|
|
587
|
+
if (url) {
|
|
588
|
+
try {
|
|
589
|
+
const parsedUrl = new URL(url);
|
|
590
|
+
const host = normalizeWebToken(parsedUrl.hostname);
|
|
591
|
+
if (host) {
|
|
592
|
+
const hostHint = `web/${host}`;
|
|
593
|
+
if (!seen.has(hostHint)) {
|
|
594
|
+
seen.add(hostHint);
|
|
595
|
+
hints.push(hostHint);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const path = normalizeWebToken(parsedUrl.pathname);
|
|
599
|
+
if (host && path && path.length >= 2) {
|
|
600
|
+
const pathHint = `web/${host}_${path}`;
|
|
601
|
+
if (!seen.has(pathHint)) {
|
|
602
|
+
seen.add(pathHint);
|
|
603
|
+
hints.push(pathHint);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
// Non-URL string — fall back to a normalized token entity.
|
|
609
|
+
const token = normalizeWebToken(url);
|
|
610
|
+
if (token) {
|
|
611
|
+
const hint = `web/${token}`;
|
|
612
|
+
if (!seen.has(hint)) {
|
|
613
|
+
seen.add(hint);
|
|
614
|
+
hints.push(hint);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return hints;
|
|
623
|
+
}
|
|
449
624
|
function advisoryTaskTokens(taskType) {
|
|
450
625
|
if (!taskType)
|
|
451
626
|
return [];
|
|
@@ -931,6 +1106,38 @@ function tokenize(text) {
|
|
|
931
1106
|
.map((part) => part.trim())
|
|
932
1107
|
.filter((part) => part.length > 2);
|
|
933
1108
|
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Determine whether injected facts are relevant to the current task context.
|
|
1111
|
+
* Uses token overlap between the task description and fact keys/summaries.
|
|
1112
|
+
* Exported for unit testing.
|
|
1113
|
+
*/
|
|
1114
|
+
function injectedFactsAreTaskRelevant(taskContext, injectedKeys, injectedSummaries) {
|
|
1115
|
+
if (!taskContext)
|
|
1116
|
+
return true;
|
|
1117
|
+
const taskTokens = new Set(tokenize(taskContext));
|
|
1118
|
+
if (taskTokens.size === 0)
|
|
1119
|
+
return true;
|
|
1120
|
+
for (const entityKey of injectedKeys) {
|
|
1121
|
+
const key = entityKey.split('/').slice(2).join('/');
|
|
1122
|
+
for (const token of tokenize(key.replace(/[_/.-]+/g, ' '))) {
|
|
1123
|
+
if (taskTokens.has(token))
|
|
1124
|
+
return true;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (injectedSummaries && injectedSummaries.length > 0) {
|
|
1128
|
+
const contentTokens = injectedSummaries
|
|
1129
|
+
.flatMap((s) => tokenize(s).filter((t) => t.length > 5));
|
|
1130
|
+
let contentMatches = 0;
|
|
1131
|
+
for (const token of contentTokens) {
|
|
1132
|
+
if (taskTokens.has(token)) {
|
|
1133
|
+
contentMatches++;
|
|
1134
|
+
if (contentMatches >= 2)
|
|
1135
|
+
return true;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return false;
|
|
1140
|
+
}
|
|
934
1141
|
function tokenizeForSearch(text) {
|
|
935
1142
|
return tokenize(text).filter((token) => !SEARCH_SUGGESTION_STOPWORDS.has(token));
|
|
936
1143
|
}
|
|
@@ -1512,6 +1719,7 @@ class AttendantInstance {
|
|
|
1512
1719
|
injectedEntryIds: [...input.injectedEntryIds],
|
|
1513
1720
|
injectedSummaries: input.injectedSummaries ? [...input.injectedSummaries] : undefined,
|
|
1514
1721
|
evidenceKinds: [],
|
|
1722
|
+
taskContext: input.taskContext,
|
|
1515
1723
|
};
|
|
1516
1724
|
this.pendingMemoryAttributions.push(attribution);
|
|
1517
1725
|
this.updateBriefPendingMemoryAttributions();
|
|
@@ -1582,6 +1790,9 @@ class AttendantInstance {
|
|
|
1582
1790
|
&& /\b(next_step|current_step|open_risks|status|checkpoint_summary|recent_file_changes|recent_actions|implementation_status|blockers?)\b/i.test(key));
|
|
1583
1791
|
});
|
|
1584
1792
|
}
|
|
1793
|
+
checkInjectedFactsTaskRelevant(attribution) {
|
|
1794
|
+
return injectedFactsAreTaskRelevant(attribution.taskContext, attribution.injectedKeys, attribution.injectedSummaries);
|
|
1795
|
+
}
|
|
1585
1796
|
scorePendingMemoryAttributions(response) {
|
|
1586
1797
|
if (this.pendingMemoryAttributions.length === 0) {
|
|
1587
1798
|
return [];
|
|
@@ -1596,18 +1807,27 @@ class AttendantInstance {
|
|
|
1596
1807
|
if (!rediscoveredManually && this.responseShowsRecoveryValue(response, entry) && !evidenceKinds.includes('response_recovery')) {
|
|
1597
1808
|
evidenceKinds.push('response_recovery');
|
|
1598
1809
|
}
|
|
1810
|
+
// Check task-relevance: if facts are not relevant to the current task,
|
|
1811
|
+
// mark as task_irrelevant so the compliance scorer does not penalize.
|
|
1812
|
+
const taskRelevant = this.checkInjectedFactsTaskRelevant(entry);
|
|
1813
|
+
if (!taskRelevant && !evidenceKinds.includes('task_irrelevant')) {
|
|
1814
|
+
evidenceKinds.push('task_irrelevant');
|
|
1815
|
+
}
|
|
1599
1816
|
const used = evidenceKinds.includes('write')
|
|
1600
1817
|
|| evidenceKinds.includes('checkpoint')
|
|
1601
1818
|
|| evidenceKinds.includes('response_reference')
|
|
1602
|
-
|| evidenceKinds.includes('response_recovery')
|
|
1819
|
+
|| evidenceKinds.includes('response_recovery')
|
|
1820
|
+
|| evidenceKinds.includes('task_irrelevant');
|
|
1603
1821
|
const helpful = evidenceKinds.includes('checkpoint')
|
|
1604
1822
|
|| evidenceKinds.includes('write')
|
|
1605
1823
|
|| evidenceKinds.includes('response_recovery');
|
|
1606
1824
|
const reason = helpful
|
|
1607
1825
|
? 'response_or_action_confirmed_memory_helpfulness'
|
|
1608
|
-
:
|
|
1609
|
-
? '
|
|
1610
|
-
:
|
|
1826
|
+
: evidenceKinds.includes('task_irrelevant')
|
|
1827
|
+
? 'injected_facts_not_relevant_to_current_task'
|
|
1828
|
+
: used
|
|
1829
|
+
? 'response_referenced_injected_memory'
|
|
1830
|
+
: 'memory_was_only_surfaced';
|
|
1611
1831
|
const scoredEntry = {
|
|
1612
1832
|
...entry,
|
|
1613
1833
|
used,
|
|
@@ -2277,11 +2497,22 @@ class AttendantInstance {
|
|
|
2277
2497
|
const baseEntityHints = this.resolveAttendEntityHints(input.entityHints, latestMessage);
|
|
2278
2498
|
// File-change demand-driven recall: extract file path mentions and add as entity hints
|
|
2279
2499
|
const filePathHints = extractFilePathEntityHints(`${latestMessage}\n${currentContext}`, (0, autoRemember_1.getProjectMemoryEntity)() ?? null);
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
//
|
|
2284
|
-
const
|
|
2500
|
+
// A2: tool-call triggered retrieval. If the caller says "I'm about to
|
|
2501
|
+
// run Read(file_path=X) / Grep / Bash / WebFetch / WebSearch", derive
|
|
2502
|
+
// structured entity hints from the tool arguments BEFORE the tool
|
|
2503
|
+
// runs, so stored facts can preempt the lookup.
|
|
2504
|
+
const toolCallHints = derivePendingToolCallEntityHints(input.pendingToolCall, (0, autoRemember_1.getProjectMemoryEntity)() ?? null);
|
|
2505
|
+
const allExtraHints = filePathHints.length === 0 && toolCallHints.length === 0
|
|
2506
|
+
? null
|
|
2507
|
+
: [...filePathHints, ...toolCallHints];
|
|
2508
|
+
const effectiveEntityHints = allExtraHints === null
|
|
2509
|
+
? baseEntityHints
|
|
2510
|
+
: [...new Set([...baseEntityHints, ...allExtraHints])];
|
|
2511
|
+
// User operating rules: load rules whose triggers match the current context.
|
|
2512
|
+
// Mid-turn attends skip this — rules were already surfaced at pre-response,
|
|
2513
|
+
// and reloading them on every mid-turn call would duplicate context and burn
|
|
2514
|
+
// an LLM call on the trigger match.
|
|
2515
|
+
const matchedUserRules = (phase !== 'post-response' && phase !== 'mid-turn')
|
|
2285
2516
|
? await this.loadMatchingUserRules(`${latestMessage}\n${currentContext}`)
|
|
2286
2517
|
: [];
|
|
2287
2518
|
let watchedEntitiesChanged = this.updateWatchedEntities(effectiveEntityHints);
|
|
@@ -2401,6 +2632,17 @@ class AttendantInstance {
|
|
|
2401
2632
|
await this.persistState();
|
|
2402
2633
|
}
|
|
2403
2634
|
(0, metrics_1.timeEnd)('attendant.attend_ms', t0);
|
|
2635
|
+
// A2: even on the "memory not needed" early return, if the caller
|
|
2636
|
+
// passed pendingToolCall, echo the derived entities so the agent
|
|
2637
|
+
// can see that attend() was tool-call-aware on this call.
|
|
2638
|
+
const skipGuidance = input.pendingToolCall
|
|
2639
|
+
? {
|
|
2640
|
+
toolName: input.pendingToolCall.name,
|
|
2641
|
+
derivedEntities: toolCallHints,
|
|
2642
|
+
factCount: 0,
|
|
2643
|
+
note: `Memory was not deemed necessary for this ${input.pendingToolCall.name} call. Proceed.`,
|
|
2644
|
+
}
|
|
2645
|
+
: undefined;
|
|
2404
2646
|
return {
|
|
2405
2647
|
shouldInject: matchedUserRules.length > 0,
|
|
2406
2648
|
reason: 'memory_not_needed',
|
|
@@ -2426,21 +2668,28 @@ class AttendantInstance {
|
|
|
2426
2668
|
hintsResolved: 0,
|
|
2427
2669
|
dropped: [{ name: latestMessage || '(none)', reason: 'memory_not_needed' }],
|
|
2428
2670
|
},
|
|
2671
|
+
toolCallGuidance: skipGuidance,
|
|
2429
2672
|
};
|
|
2430
2673
|
}
|
|
2431
2674
|
// Post-compaction recovery: re-surface facts that were recently injected (likely in context
|
|
2432
2675
|
// just before the compact) without blocking them on the already-in-context filter.
|
|
2433
2676
|
// The flag is set by handshake(postCompaction:true) and consumed exactly once here.
|
|
2434
2677
|
const postCompactionRecoveryKeys = [];
|
|
2435
|
-
let
|
|
2678
|
+
let effectiveMaxFacts = input.maxFacts;
|
|
2436
2679
|
if (this.postCompactionPending) {
|
|
2437
2680
|
const recentInjections = this.pendingMemoryAttributions.slice(-5);
|
|
2438
2681
|
for (const attr of recentInjections) {
|
|
2439
2682
|
postCompactionRecoveryKeys.push(...attr.injectedKeys);
|
|
2440
2683
|
}
|
|
2441
|
-
|
|
2684
|
+
effectiveMaxFacts = Math.min((input.maxFacts ?? 5) * 2, 10);
|
|
2442
2685
|
this.postCompactionPending = false;
|
|
2443
2686
|
}
|
|
2687
|
+
else if (phase === 'mid-turn' && input.maxFacts === undefined) {
|
|
2688
|
+
// A1: mid-turn attends default to a smaller fact budget than pre-response.
|
|
2689
|
+
// The agent already has a working-memory frame from the pre-response attend;
|
|
2690
|
+
// mid-turn is about surfacing 1-3 NEW facts on a topic shift, not re-dumping briefs.
|
|
2691
|
+
effectiveMaxFacts = MID_TURN_DEFAULT_MAX_FACTS;
|
|
2692
|
+
}
|
|
2444
2693
|
const observeEntityHints = effectiveEntityHints.length > 0 ? effectiveEntityHints : freshState.entities;
|
|
2445
2694
|
const allObserveEntityHints = postCompactionRecoveryKeys.length > 0
|
|
2446
2695
|
? [...new Set([
|
|
@@ -2453,7 +2702,7 @@ class AttendantInstance {
|
|
|
2453
2702
|
: observeEntityHints;
|
|
2454
2703
|
const observed = await this.observe({
|
|
2455
2704
|
currentContext: observationContext,
|
|
2456
|
-
maxFacts:
|
|
2705
|
+
maxFacts: effectiveMaxFacts,
|
|
2457
2706
|
entityHints: allObserveEntityHints,
|
|
2458
2707
|
priorityKeys: expandContinuityPriorityKeys(Array.from(new Set([
|
|
2459
2708
|
...(mandatoryRecall.key ? [mandatoryRecall.key] : []),
|
|
@@ -2477,7 +2726,31 @@ class AttendantInstance {
|
|
|
2477
2726
|
const remainder = slashIdx2 === -1 ? '' : fact.entityKey.slice(slashIdx2);
|
|
2478
2727
|
return { ...fact, entityKey: `${canonicalPersonalType}/${canonicalPersonalId}${remainder}` };
|
|
2479
2728
|
});
|
|
2480
|
-
|
|
2729
|
+
// A1: mid-turn dedup — drop facts whose entityKey was already injected earlier
|
|
2730
|
+
// in the SAME turn. pendingMemoryAttributions is reset at post-response, so any
|
|
2731
|
+
// entry in that list belongs to the current turn. This prevents mid-turn attends
|
|
2732
|
+
// from spamming the agent with facts it already has in working memory from a
|
|
2733
|
+
// previous pre-response or mid-turn attend call.
|
|
2734
|
+
const midTurnFilteredKeys = [];
|
|
2735
|
+
let factsAfterDedup = remappedFacts;
|
|
2736
|
+
if (phase === 'mid-turn' && this.pendingMemoryAttributions.length > 0) {
|
|
2737
|
+
const alreadyInjectedThisTurn = new Set();
|
|
2738
|
+
for (const attr of this.pendingMemoryAttributions) {
|
|
2739
|
+
for (const key of attr.injectedKeys) {
|
|
2740
|
+
alreadyInjectedThisTurn.add(key);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
if (alreadyInjectedThisTurn.size > 0) {
|
|
2744
|
+
factsAfterDedup = remappedFacts.filter((fact) => {
|
|
2745
|
+
if (alreadyInjectedThisTurn.has(fact.entityKey)) {
|
|
2746
|
+
midTurnFilteredKeys.push(fact.entityKey);
|
|
2747
|
+
return false;
|
|
2748
|
+
}
|
|
2749
|
+
return true;
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
const structuredFacts = (0, hostMemoryFormatting_1.assignStructuredFactIds)(factsAfterDedup);
|
|
2481
2754
|
watchedEntitiesChanged = this.updateWatchedEntities(observed.entitiesResolved?.map((entry) => entry.canonicalEntity) ?? []) || watchedEntitiesChanged;
|
|
2482
2755
|
this.markSharedStateObserved(observeEntityHints.length > 0 ? observeEntityHints : freshState.entities);
|
|
2483
2756
|
let reason = 'memory_needed_injected';
|
|
@@ -2486,8 +2759,14 @@ class AttendantInstance {
|
|
|
2486
2759
|
const memoryResultsConsidered = observed.totalFound;
|
|
2487
2760
|
let searchSuggestion;
|
|
2488
2761
|
if (!shouldInject) {
|
|
2762
|
+
// A1: if mid-turn dedup ate everything observe returned, treat it as
|
|
2763
|
+
// "already in context" — the agent has these facts from an earlier attend.
|
|
2764
|
+
const allDroppedByMidTurnDedup = phase === 'mid-turn'
|
|
2765
|
+
&& midTurnFilteredKeys.length > 0
|
|
2766
|
+
&& remappedFacts.length > 0
|
|
2767
|
+
&& factsAfterDedup.length === 0;
|
|
2489
2768
|
const allAlreadyInContext = observed.totalFound > 0 && observed.alreadyPresent >= observed.totalFound;
|
|
2490
|
-
reason = allAlreadyInContext ? 'memory_needed_but_in_context' : 'memory_checked_no_match';
|
|
2769
|
+
reason = (allAlreadyInContext || allDroppedByMidTurnDedup) ? 'memory_needed_but_in_context' : 'memory_checked_no_match';
|
|
2491
2770
|
if (reason === 'memory_checked_no_match') {
|
|
2492
2771
|
const terms = tokenizeForSearch(latestMessage).slice(0, 6);
|
|
2493
2772
|
const alternativeEntities = (observed.entitiesResolved ?? [])
|
|
@@ -2514,11 +2793,34 @@ class AttendantInstance {
|
|
|
2514
2793
|
.map((fact) => fact.knowledgeEntryId)
|
|
2515
2794
|
.filter((value) => typeof value === 'number'),
|
|
2516
2795
|
injectedSummaries: structuredFacts.map((fact) => fact.summary).filter(Boolean),
|
|
2796
|
+
taskContext: this.brief?.inferredTaskType,
|
|
2517
2797
|
}),
|
|
2518
2798
|
]
|
|
2519
2799
|
: [];
|
|
2800
|
+
// A1: when mid-turn dedup filtered keys, surface them in debug so the
|
|
2801
|
+
// caller (and tests) can reason about which facts were suppressed.
|
|
2802
|
+
const debugWithMidTurn = (midTurnFilteredKeys.length > 0 && observed.debug)
|
|
2803
|
+
? { ...observed.debug, midTurnFilteredKeys: [...midTurnFilteredKeys] }
|
|
2804
|
+
: observed.debug;
|
|
2805
|
+
// A2: if the caller supplied pendingToolCall, surface a guidance block
|
|
2806
|
+
// so the agent can see what entities were derived and how many stored
|
|
2807
|
+
// facts preempted the lookup. Only populated when a tool call was
|
|
2808
|
+
// actually passed — no allocation overhead for the common path.
|
|
2809
|
+
const toolCallGuidance = input.pendingToolCall
|
|
2810
|
+
? {
|
|
2811
|
+
toolName: input.pendingToolCall.name,
|
|
2812
|
+
derivedEntities: toolCallHints,
|
|
2813
|
+
factCount: structuredFacts.length,
|
|
2814
|
+
note: toolCallHints.length === 0
|
|
2815
|
+
? `No entity hints derived from ${input.pendingToolCall.name} args. Memory check ran on message/context only.`
|
|
2816
|
+
: structuredFacts.length > 0
|
|
2817
|
+
? `Iranti surfaced ${structuredFacts.length} stored fact(s) for this ${input.pendingToolCall.name} call. Read them before running the tool — you may not need to run it.`
|
|
2818
|
+
: `No stored facts matched the ${input.pendingToolCall.name} target. Proceed with the tool call.`,
|
|
2819
|
+
}
|
|
2820
|
+
: undefined;
|
|
2520
2821
|
const attendResult = {
|
|
2521
2822
|
...observed,
|
|
2823
|
+
debug: debugWithMidTurn,
|
|
2522
2824
|
facts: structuredFacts,
|
|
2523
2825
|
shouldInject: structuredFacts.length > 0 || matchedUserRules.length > 0,
|
|
2524
2826
|
reason,
|
|
@@ -2532,6 +2834,7 @@ class AttendantInstance {
|
|
|
2532
2834
|
memoryResultsConsidered,
|
|
2533
2835
|
matchedUserRules: matchedUserRules.length > 0 ? matchedUserRules : undefined,
|
|
2534
2836
|
usageGuidance: buildUsageGuidance('attend', this.turnsWithoutWrite),
|
|
2837
|
+
toolCallGuidance,
|
|
2535
2838
|
};
|
|
2536
2839
|
if (input.suppressEvents !== true) {
|
|
2537
2840
|
(0, staffEventRegistry_1.getStaffEventEmitter)().emit({
|