kiokuko-dsh 0.2.13 → 0.2.15

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.
@@ -1,3 +1,4 @@
1
+ import { abortable } from './boundary-worker.js';
1
2
  import { isLispImplementation, isLispNativeCommand, withRootLispAdmission, rememberLispConversation } from './lisp-tool-identity.js';
2
3
  import { ExecutionSelectionPending } from './execution-pending.js';
3
4
  import { executionClarification } from './intake-clarification.js';
@@ -6,9 +7,11 @@ import { z } from 'zod';
6
7
  import { TASK_TYPES } from '../akinator/types.js';
7
8
  import { canonicalContentHash } from '../serialization/validate.js';
8
9
  import { KIOKUKO_DSH_SOURCE_KIND } from './plugin-source.js';
9
- import { taskAllowedOperations } from './task-state.js';
10
- import { createRequestIntentContext, unresolvedRequestIntent, validateMainModelRequestIntent, requestIntentReferenceIds, requestIntentMessageRefs, requestIntentTaskType, RequestIntentSubmissionSchema } from './decisions/request-intent.js';
10
+ import { taskAllowedOperations, taskCanExecute } from './task-state.js';
11
+ import { createRequestIntentContext, unresolvedRequestIntent, validateMainModelRequestIntent, requestIntentReferenceIds, requestIntentRequiredReferenceIds, requestIntentIsUnresolved, requestIntentMessageRefs, requestIntentTaskType, RequestIntentSubmissionSchema } from './decisions/request-intent.js';
11
12
  import { memoryOwnerKind } from './memory-owner.js';
13
+ class RequestClarification extends Error {
14
+ }
12
15
  export const TASK_PREPARE_TOOL = 'prepare_requested_work';
13
16
  export const ON_DEMAND_GUIDANCE = 'The original user request may be answered now without selecting a task category. Ordinary native Bash needs no memory, Goal or prepare_requested_work round trip, including when optional workflow metadata exists; native permissions and human Stop remain authoritative. Registered root Lisp tools delegate to the Lisp runtime without an optional preparation round trip; foreground processes use native Bash permissions, sandbox, approval and Stop. Explicitly prepared or protected workflow effects retain their task, Plan and coding-choice checks. Owned or explicitly enrolled work retains its authority checks. Preserve the original utterance, every requested action, negation, quotation, target uncertainty, and current-session references; never replace it with a model rewrite. Use only the supplied active Goal and proposal references from this exact native session, never another chat. Classify relation as new, same_scope_continuation, scope_change, or unknown, and action as answer, research, verify, change, operate, or unknown; do not infer either from a fixed phrase or tool name. “Check how it behaves” is verification/research even when the next tool is Bash. This interpretation grants no native permission. When the decision provider is unavailable, abstains, or returns unknown, interpret the same context in the main model; ask the user only if the action or referenced target remains unclear, and preserve active work while waiting. Native read, glob, grep, web_search and web_fetch demands can gather evidence without preparation. Do not call prepare_requested_work merely to read a plan or inspect files. prepare_requested_work accepts either the legacy taskType alone or a requestIntent with exact evidenceRefs from the supplied context. taskOperation is optional and describes a transition of an existing activeTask, not starting the requested work itself. Omit taskOperation when activeTask is null or the request does not ask for a state transition; it never accepts a rewritten task or target and never changes native permissions. Installed Skill reads need no preparation and do not activate execution. Do not prepare work merely to load a skill. Plan, Goal, cancellation, memory-owner and execution ownership still apply. If only run_code is exposed, use the exact preparation-only carrier returned by this tool contract; the host intercepts it without evaluating code.';
14
17
  const ACTION_TYPES = TASK_TYPES.filter(type => type !== 'chat');
@@ -35,7 +38,7 @@ function eventTurn(event) {
35
38
  }
36
39
  /** Tool names select advice only; admission and native permissions remain authoritative. */
37
40
  function automaticPreparationType(intent, requestIntent, name) {
38
- if (requestIntent?.relation === 'unknown' || requestIntent?.action === 'unknown') {
41
+ if (requestIntent !== undefined && requestIntentIsUnresolved(requestIntent)) {
39
42
  // Gathering evidence grants no change/process authority. An active Goal
40
43
  // still requires its reference to be resolved before opening another run.
41
44
  return ['read', 'glob', 'grep', 'web_search', 'web_fetch'].includes(name) ? 'research' : undefined;
@@ -49,6 +52,7 @@ function automaticPreparationType(intent, requestIntent, name) {
49
52
  return intent.taskType;
50
53
  return ['read', 'glob', 'grep', 'web_search', 'web_fetch'].includes(name) ? 'research' : undefined;
51
54
  }
55
+ const NATIVE_EVIDENCE_READS = new Set(['read', 'glob', 'grep', 'web_search', 'web_fetch']);
52
56
  const UNOWNED_NATIVE_TOOLS = new Set(['bash', 'read', 'glob', 'grep', 'web_search', 'web_fetch', 'skill']);
53
57
  function denied(reason) { return { kind: 'deny', reason }; }
54
58
  /** A protocol carrier, not JavaScript evaluation. Only this exact JSON-literal call is recognized. */
@@ -79,6 +83,7 @@ export class OnDemandIntake {
79
83
  host;
80
84
  options;
81
85
  #turns = new Map();
86
+ #clarifications = new Map();
82
87
  #executions = new WeakMap();
83
88
  #disposers = [];
84
89
  #pending = new Set();
@@ -260,6 +265,10 @@ export class OnDemandIntake {
260
265
  catch (error) {
261
266
  input.signal.throwIfAborted();
262
267
  }
268
+ const pendingClarification = this.#clarifications.get(sessionId);
269
+ if (pendingClarification?.agent === input.agent && state.requestIntent && !requestIntentIsUnresolved(state.requestIntent)
270
+ && !executionClarification(state.task, state.requestIntent, state.requestIntentContext))
271
+ this.#clarifications.delete(sessionId);
263
272
  state.memoryOwner = memoryOwnerKind(state.requestIntent, state.intent.taskType);
264
273
  this.assertCurrent(state);
265
274
  if (previous?.input.turn !== input.turn)
@@ -288,7 +297,7 @@ export class OnDemandIntake {
288
297
  return false;
289
298
  }
290
299
  const canContinue = state.requestIntent?.relation === 'same_scope_continuation'
291
- && state.requestIntent.action !== 'answer' && state.requestIntent.action !== 'unknown';
300
+ && !requestIntentIsUnresolved(state.requestIntent) && state.requestIntent.action !== 'answer';
292
301
  if (previous?.input.turn !== input.turn && state.task && canContinue
293
302
  && await this.host.existing(input, state.requestIntent)) {
294
303
  // Persisted ownership is only an acknowledgement when this exact native
@@ -302,13 +311,15 @@ export class OnDemandIntake {
302
311
  // Eager mode still binds known work before generation. Classifier uncertainty
303
312
  // instead reaches ordinary reasoning, with no execution grant or purpose UI.
304
313
  const requestIntentUnresolved = state.requestIntent !== undefined
305
- && (state.requestIntent.relation === 'unknown' || state.requestIntent.action === 'unknown');
314
+ && requestIntentIsUnresolved(state.requestIntent);
306
315
  const requestAsAnswer = state.requestIntent?.action === 'answer';
307
316
  const knownIntent = this.host.interpret === undefined
308
317
  ? !state.intent.deferInference && state.intent.taskType !== undefined && state.intent.taskType !== 'chat'
309
318
  : state.requestIntent !== undefined && !requestIntentUnresolved && !requestAsAnswer
310
319
  && state.requestIntent.relation !== 'unknown' && state.requestIntent.action !== 'unknown';
311
- if (this.options.onlyWhenDeferred && knownIntent) {
320
+ const missingExecutionDetail = executionClarification(state.task, state.requestIntent, state.requestIntentContext)
321
+ || state.requestIntentContext?.activeTask && !taskCanExecute(state.requestIntentContext.activeTask);
322
+ if (this.options.onlyWhenDeferred && knownIntent && !missingExecutionDetail) {
312
323
  this.#turns.delete(sessionId);
313
324
  state.status = 'closed';
314
325
  controller.abort(new Error('Known intent continues through eager admission'));
@@ -375,9 +386,19 @@ export class OnDemandIntake {
375
386
  if (state.selectionPending)
376
387
  throw new ExecutionSelectionPending();
377
388
  const resolved = requestIntent ?? state.requestIntent;
389
+ if (resolved?.action === 'change' && resolved.changeScope === 'unknown') {
390
+ throw new RequestClarification('The requested change scope is unresolved. Specify implementation or document using the current request evidence before preparing work.');
391
+ }
392
+ if (resolved && requestIntentIsUnresolved(resolved) && (state.requestIntentContext?.activeGoal || state.requestIntentContext?.activeTask)) {
393
+ throw new RequestClarification('Clarify the request before preparing work');
394
+ }
395
+ const activeTask = state.requestIntentContext?.activeTask;
396
+ if (activeTask && resolved?.relation === 'same_scope_continuation' && !taskCanExecute(activeTask) && !resolved.taskOperation) {
397
+ throw new RequestClarification('The current task requires an explicit user-selected state operation before execution.');
398
+ }
378
399
  const clarification = executionClarification(state.task, resolved, state.requestIntentContext);
379
400
  if (clarification)
380
- throw new Error(clarification);
401
+ throw new RequestClarification(clarification);
381
402
  if (!ACTION_TYPES.includes(taskType))
382
403
  throw new Error('Answer conversational questions directly; prepare_requested_work is for requested work');
383
404
  if (state.status === 'prepared' && state.confirmationResume && explicitIntent
@@ -437,9 +458,104 @@ export class OnDemandIntake {
437
458
  delete state.preparation;
438
459
  }
439
460
  }
461
+ /** A question is a waiting state, never an execution grant. */
462
+ awaitingClarification(agent) {
463
+ const pending = agent.session && this.#clarifications.get(agent.session.id);
464
+ if (this.#stopped || pending?.agent !== agent)
465
+ return false;
466
+ const boundary = agent.session?.snapshotEvents?.().filter(event => event.type === 'turn/end').at(-1);
467
+ const data = boundary?.data;
468
+ if (data?.turn !== undefined && data.turn >= pending.turn && ['aborted', 'error', 'max-tokens', 'blocked'].includes(data.reason?.kind ?? '')) {
469
+ this.#clarifications.delete(agent.session.id);
470
+ return false;
471
+ }
472
+ return true;
473
+ }
474
+ async clarify(state, executionSignal, reason) {
475
+ this.assertCurrent(state);
476
+ const signal = AbortSignal.any([executionSignal, state.input.signal, state.controller.signal]);
477
+ const context = state.requestIntentContext;
478
+ const sessionId = state.input.agent.session.id;
479
+ const scope = state.requestIntent?.action === 'change' && state.requestIntent.changeScope === 'unknown'
480
+ || reason.message.startsWith('The requested change scope');
481
+ const target = !!executionClarification(state.task, state.requestIntent, context);
482
+ const transition = context?.activeTask && !taskCanExecute(context.activeTask);
483
+ const question = transition ? `現在のタスクは ${context.activeTask.state} です。続けるために、どの操作を希望しますか?(${taskAllowedOperations(context.activeTask).join(' / ')})`
484
+ : target ? 'どの具体的なファイル・項目・相手・操作を対象にしますか?'
485
+ : scope ? '変更するのは文章だけですか、それともコード・テスト・設定も含みますか?'
486
+ : state.requestIntent?.action === 'unknown' ? 'その対象に、具体的に何をしてほしいですか?'
487
+ : '今回の依頼は現在の作業の続きですか、それとも別の作業ですか?';
488
+ const key = canonicalContentHash({ context: context ?? null, task: state.task, humanEvidence: [...state.humanContents], question });
489
+ const previous = this.#clarifications.get(sessionId);
490
+ if (previous?.agent === state.input.agent && previous.key === key)
491
+ return previous.operation ?? previous.result;
492
+ const id = `request-clarification-${key.slice(0, 20)}`;
493
+ const result = { prepared: false, status: 'awaiting_clarification', question, questionId: id, originalTask: state.task,
494
+ permissions: 'unchanged_native_policy', nextAction: this.host.questions ? 'wait_for_user' : 'ask_user_once' };
495
+ const pending = { agent: state.input.agent, turn: state.input.turn, key, status: 'waiting', result: result,
496
+ operation: undefined };
497
+ this.#clarifications.set(sessionId, pending);
498
+ if (!this.host.questions || !state.input.agent.followup)
499
+ return result;
500
+ const operation = (async () => {
501
+ try {
502
+ const response = await abortable(this.host.questions.ask({ agent: state.input.agent, signal,
503
+ questions: [{ id, question, ...(scope && !target && !transition ? { options: [{ label: '文章のみ' }, { label: 'コード・テスト・設定を含む' }] } : {}) }] }), signal);
504
+ signal.throwIfAborted();
505
+ this.assertCurrent(state);
506
+ await this.host.validate(state.input);
507
+ signal.throwIfAborted();
508
+ this.assertCurrent(state);
509
+ const current = await this.host.interpret?.(state.input, state.task);
510
+ signal.throwIfAborted();
511
+ this.assertCurrent(state);
512
+ if (context && (!current || canonicalContentHash(current.context) !== canonicalContentHash(context))) {
513
+ if (current) {
514
+ state.requestIntentContext = current.context;
515
+ state.requestIntent = current.decision;
516
+ state.requestIntentContextDigest = canonicalContentHash(current.context);
517
+ }
518
+ pending.result = { ...result, ...(current ? { currentReferences: requestIntentRequiredReferenceIds(current.context) } : {}), status: 'clarification_context_changed', detail: 'The task or current evidence changed while awaiting the answer. Refresh the current request before asking again.' };
519
+ return pending.result;
520
+ }
521
+ const answer = response.answers[0];
522
+ const value = answer?.custom?.trim() || answer?.selected[0]?.trim();
523
+ if (answer?.id !== id || !value || value.length > 8192)
524
+ return result;
525
+ // Both texts originate from the human: preserve the original verbatim,
526
+ // label the actual UI answer separately, and re-enter ordinary native
527
+ // capture. The reply itself is never mapped to execution authority.
528
+ const content = `Original user request (unchanged):\n${state.task}\n\nUser answer to clarification "${question}":\n${value}`;
529
+ signal.throwIfAborted();
530
+ this.assertCurrent(state);
531
+ state.input.agent.followup({ id: randomUUID(), role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: content }] });
532
+ pending.status = 'queued';
533
+ pending.result = { ...result, status: 'clarification_answer_queued', answer: value };
534
+ return pending.result;
535
+ }
536
+ catch (error) {
537
+ if (signal.aborted) {
538
+ if (this.#clarifications.get(sessionId) === pending)
539
+ this.#clarifications.delete(sessionId);
540
+ throw error;
541
+ }
542
+ // A dismissed/unavailable question surface remains waiting, never approved.
543
+ return result;
544
+ }
545
+ finally {
546
+ pending.operation = undefined;
547
+ }
548
+ })();
549
+ pending.operation = operation;
550
+ return this.watch(operation);
551
+ }
440
552
  bindIntent(state, submission) {
441
- if (!submission)
553
+ if (!submission) {
554
+ if (state.requestIntent?.action === 'change' && state.requestIntent.changeScope === 'unknown') {
555
+ throw new RequestClarification('The requested change scope is unresolved. Specify implementation or document using the current request evidence before preparing work.');
556
+ }
442
557
  return state.requestIntent;
558
+ }
443
559
  if (!state.requestIntentContext || state.requestIntentContextDigest !== canonicalContentHash(state.requestIntentContext)) {
444
560
  throw new Error('Request interpretation context is stale');
445
561
  }
@@ -449,7 +565,7 @@ export class OnDemandIntake {
449
565
  case 'no_active_goal': throw new Error(`Request relation ${submission.relation} cannot be used without an active Goal. ${NO_ACTIVE_GOAL_GUIDANCE}`);
450
566
  case 'missing_reference': throw new Error('Request interpretation is missing required current message or active Goal references. Use the exact current evidenceRefs from the supplied context.');
451
567
  case 'unknown_reference': throw new Error('Request interpretation references another request, stale revision, or unknown proposal. Use only the exact current evidenceRefs from the supplied context.');
452
- case 'unresolved_intent': throw new Error('Request relation or action is unresolved. Interpret the original request and current context; clarify with the user if its action or target remains unclear.');
568
+ case 'unresolved_intent': throw new RequestClarification('Request relation or action is unresolved. Interpret the original request and current context; clarify with the user if its action or target remains unclear.');
453
569
  case 'invalid_submission': throw new Error('Invalid request interpretation. Supply a relation, action and unique exact current evidenceRefs.');
454
570
  case 'invalid_task_operation': {
455
571
  const task = state.requestIntentContext.activeTask;
@@ -461,6 +577,9 @@ export class OnDemandIntake {
461
577
  }
462
578
  }
463
579
  const bound = result.decision;
580
+ if (bound.action === 'change' && bound.changeScope === 'unknown') {
581
+ throw new RequestClarification('The requested change scope is unresolved. Specify implementation or document using the current request evidence before preparing work.');
582
+ }
464
583
  const prior = state.requestIntent;
465
584
  if (prior?.source === 'decision-provider' && prior.relation !== 'unknown' && prior.action !== 'unknown') {
466
585
  if (prior.relation !== bound.relation || prior.action !== bound.action) {
@@ -497,12 +616,14 @@ export class OnDemandIntake {
497
616
  const interpretation = state?.requestIntent;
498
617
  const intentText = context ? `\n\nCurrent request references and same-session work (host-bound, advisory only):\n${JSON.stringify({
499
618
  references: requestIntentReferenceIds(context),
619
+ requiredReferences: requestIntentRequiredReferenceIds(context),
500
620
  activeGoal: context.activeGoal ?? null,
501
621
  activeTask: context.activeTask ?? null,
502
- proposals: context.proposals.map(({ id, revision, digest, text }) => ({ id, revision, digest, text })),
622
+ proposals: context.proposals.map(({ id, revision, digest, text }) => ({ id, revision, digest, text,
623
+ evidenceRef: `proposal:${canonicalContentHash([id, revision, digest])}` })),
503
624
  decision: interpretation ? { relation: interpretation.relation, action: interpretation.action, source: interpretation.source,
504
625
  policyVersion: interpretation.policyVersion, evidence: interpretation.evidence, unresolvedReason: interpretation.unresolvedReason ?? null } : null,
505
- })}\nUse only these exact references in requestIntent.evidenceRefs. The original human message remains unchanged.` : '';
626
+ })}\nInclude every requiredReferences entry in requestIntent.evidenceRefs. Add a proposal evidenceRef only when the current user request actually adopts or refers to that proposal; do not copy all proposals automatically. Use only these exact references. The original human message remains unchanged.` : '';
506
627
  const text = ON_DEMAND_GUIDANCE + intentText + (context && !context.activeGoal && !context.activeTask ? `\n${NO_ACTIVE_GOAL_GUIDANCE}` : '');
507
628
  return [...messages, { id: randomUUID(), role: 'user', content: [{ type: 'text', text }],
508
629
  source: { kind: KIOKUKO_DSH_SOURCE_KIND, form: 'snapshot', sections: [{ name: 'on-demand-intake', text }] } }];
@@ -625,53 +746,60 @@ export class OnDemandIntake {
625
746
  if (state.preparationCalls.has(execution.callId) || !(state.status === 'answer' || state.status === 'prepared' && this.host.ready(state.input.agent, state.input.turn)))
626
747
  throw new Error('Task preparation was already consumed for this turn');
627
748
  state.preparationCalls.add(execution.callId);
628
- if (state.existing) {
629
- this.assertCurrent(state);
630
- execution.signal.throwIfAborted();
631
- const boundIntent = this.bindIntent(state, value.requestIntent);
632
- if (boundIntent?.relation === 'same_scope_continuation' && boundIntent.action !== 'answer' && boundIntent.action !== 'unknown') {
633
- const intentType = requestIntentTaskType(boundIntent, state.taskType ?? state.intent.taskType ?? value.taskType);
634
- if (value.taskType !== undefined && intentType !== undefined && value.taskType !== intentType) {
635
- throw new Error('The advisory taskType conflicts with the bound request action');
749
+ try {
750
+ if (state.existing) {
751
+ this.assertCurrent(state);
752
+ execution.signal.throwIfAborted();
753
+ const boundIntent = this.bindIntent(state, value.requestIntent);
754
+ if (boundIntent?.relation === 'same_scope_continuation' && !requestIntentIsUnresolved(boundIntent) && boundIntent.action !== 'answer') {
755
+ const intentType = requestIntentTaskType(boundIntent, state.taskType ?? state.intent.taskType ?? value.taskType);
756
+ if (value.taskType !== undefined && intentType !== undefined && value.taskType !== intentType) {
757
+ throw new Error('The advisory taskType conflicts with the bound request action');
758
+ }
759
+ state.requestIntent = boundIntent;
760
+ }
761
+ else {
762
+ if (!boundIntent || requestIntentIsUnresolved(boundIntent) || boundIntent.action === 'answer') {
763
+ throw new RequestClarification('Clarify whether this request continues, changes, or starts work before preparing it');
764
+ }
765
+ const intentType = requestIntentTaskType(boundIntent, state.taskType ?? state.intent.taskType ?? value.taskType);
766
+ if (!intentType || intentType === 'chat' || value.taskType !== undefined && value.taskType !== intentType) {
767
+ throw new Error('The advisory taskType conflicts with the bound request action');
768
+ }
769
+ state.existing = false;
770
+ state.status = 'answer';
771
+ state.memoryOwner = memoryOwnerKind(boundIntent, intentType);
772
+ await this.prepare(state, intentType, execution.signal, boundIntent, value.requestIntent !== undefined);
636
773
  }
637
- state.requestIntent = boundIntent;
638
774
  }
639
775
  else {
640
- if (!boundIntent || boundIntent.relation === 'unknown' || boundIntent.action === 'unknown' || boundIntent.action === 'answer') {
641
- throw new Error('Clarify whether this request continues, changes, or starts work before preparing it');
776
+ const suppliedIntent = this.bindIntent(state, value.requestIntent);
777
+ const currentIntent = suppliedIntent ?? state.requestIntent;
778
+ const unresolved = currentIntent !== undefined && requestIntentIsUnresolved(currentIntent);
779
+ if (unresolved && (state.requestIntentContext?.activeGoal || state.requestIntentContext?.activeTask || value.requestIntent !== undefined || value.taskType === undefined)) {
780
+ throw new RequestClarification('Clarify the request before preparing work');
781
+ }
782
+ if (state.requestIntentContext?.activeGoal && currentIntent === undefined) {
783
+ throw new RequestClarification('Clarify whether this request continues, changes, or starts the active Goal');
642
784
  }
643
- const intentType = requestIntentTaskType(boundIntent, state.taskType ?? state.intent.taskType ?? value.taskType);
644
- if (!intentType || intentType === 'chat' || value.taskType !== undefined && value.taskType !== intentType) {
785
+ const intentType = unresolved ? undefined : requestIntentTaskType(currentIntent, state.taskType ?? state.intent.taskType ?? value.taskType);
786
+ if (value.taskType !== undefined && intentType !== undefined && value.taskType !== intentType) {
645
787
  throw new Error('The advisory taskType conflicts with the bound request action');
646
788
  }
647
- state.existing = false;
648
- state.status = 'answer';
649
- state.memoryOwner = memoryOwnerKind(boundIntent, intentType);
650
- await this.prepare(state, intentType, execution.signal, boundIntent, value.requestIntent !== undefined);
789
+ const taskType = intentType ?? value.taskType;
790
+ if (!taskType || taskType === 'chat' || !ACTION_TYPES.includes(taskType))
791
+ throw new RequestClarification('Answer ordinary questions directly; clarify unknown actions before preparing work');
792
+ state.memoryOwner = memoryOwnerKind(currentIntent, taskType);
793
+ await this.prepare(state, taskType, execution.signal, currentIntent, value.requestIntent !== undefined);
651
794
  }
795
+ return { prepared: true, taskType: state.taskType ?? value.taskType ?? requestIntentTaskType(state.requestIntent), originalTask: state.task,
796
+ ...(state.requestIntent ? { requestIntent: state.requestIntent } : {}), memoryOwner: state.memoryOwner, permissions: 'unchanged_native_policy' };
652
797
  }
653
- else {
654
- const suppliedIntent = this.bindIntent(state, value.requestIntent);
655
- const currentIntent = suppliedIntent ?? state.requestIntent;
656
- const unresolved = currentIntent?.relation === 'unknown' || currentIntent?.action === 'unknown';
657
- if (unresolved && (state.requestIntentContext?.activeGoal || value.requestIntent !== undefined || value.taskType === undefined)) {
658
- throw new Error('Clarify the request before preparing work');
659
- }
660
- if (state.requestIntentContext?.activeGoal && currentIntent === undefined) {
661
- throw new Error('Clarify whether this request continues, changes, or starts the active Goal');
662
- }
663
- const intentType = unresolved ? undefined : requestIntentTaskType(currentIntent, state.taskType ?? state.intent.taskType ?? value.taskType);
664
- if (value.taskType !== undefined && intentType !== undefined && value.taskType !== intentType) {
665
- throw new Error('The advisory taskType conflicts with the bound request action');
666
- }
667
- const taskType = intentType ?? value.taskType;
668
- if (!taskType || taskType === 'chat' || !ACTION_TYPES.includes(taskType))
669
- throw new Error('Answer ordinary questions directly; clarify unknown actions before preparing work');
670
- state.memoryOwner = memoryOwnerKind(currentIntent, taskType);
671
- await this.prepare(state, taskType, execution.signal, currentIntent, value.requestIntent !== undefined);
798
+ catch (error) {
799
+ if (!(error instanceof RequestClarification))
800
+ throw error;
801
+ return this.clarify(state, execution.signal, error);
672
802
  }
673
- return { prepared: true, taskType: state.taskType ?? value.taskType ?? requestIntentTaskType(state.requestIntent), originalTask: state.task,
674
- ...(state.requestIntent ? { requestIntent: state.requestIntent } : {}), memoryOwner: state.memoryOwner, permissions: 'unchanged_native_policy' };
675
803
  } };
676
804
  this.#disposers.push(tools.register(definition));
677
805
  this.#disposers.push(context.on('tools/pre-execute', async (execution, next) => {
@@ -725,6 +853,17 @@ export class OnDemandIntake {
725
853
  const decision = await next();
726
854
  return decision?.kind === 'cancel' ? denied('Native tool policy cancelled this call') : decision;
727
855
  }
856
+ // Evidence gathering does not decide the request's action or scope.
857
+ // In particular, an inherited owner must not turn a read into an
858
+ // immutable research preparation before the main model can interpret it.
859
+ if (state.status === 'answer' && !state.nativeUnowned && execution.parent === undefined && NATIVE_EVIDENCE_READS.has(execution.name)) {
860
+ const clarification = executionClarification(state.task, state.requestIntent, state.requestIntentContext);
861
+ if (clarification)
862
+ return denied(clarification);
863
+ this.#executions.set(execution, { turn: state, execute: actual.execute, evidenceReadDigest: canonicalContentHash(execution.arguments) });
864
+ const decision = await next();
865
+ return decision?.kind === 'cancel' ? denied('Native tool policy cancelled this call') : decision;
866
+ }
728
867
  if (state.nativeUnowned && UNOWNED_NATIVE_TOOLS.has(execution.name) && execution.parent === undefined) {
729
868
  if (await this.host.nativeOwnership?.(state.input) !== 'unowned') {
730
869
  state.nativeUnowned = false;
@@ -774,6 +913,15 @@ export class OnDemandIntake {
774
913
  return decision?.kind === 'cancel' ? denied('Native tool policy cancelled this call') : decision;
775
914
  }
776
915
  catch (error) {
916
+ if (error instanceof RequestClarification) {
917
+ try {
918
+ await this.clarify(state, execution.signal, error);
919
+ return denied('Awaiting user clarification; no work was executed. Continue through the clarified human request.');
920
+ }
921
+ catch (cancelled) {
922
+ return denied(cancelled instanceof Error ? cancelled.message : 'Clarification cancelled');
923
+ }
924
+ }
777
925
  return denied(error instanceof Error ? error.message : 'Execution preparation failed');
778
926
  }
779
927
  }, { prepend: true }));
@@ -809,6 +957,12 @@ export class OnDemandIntake {
809
957
  throw new Error('Native Lisp identity or arguments changed');
810
958
  return withRootLispAdmission(execution, () => this.nativeLisp(execution), next);
811
959
  }
960
+ if (proof.evidenceReadDigest !== undefined) {
961
+ if (state.status !== 'answer' || execution.parent !== undefined || !NATIVE_EVIDENCE_READS.has(execution.name)
962
+ || canonicalContentHash(execution.arguments) !== proof.evidenceReadDigest)
963
+ throw new Error('Native evidence read identity or arguments changed');
964
+ return next();
965
+ }
812
966
  if (proof.nativeUnownedDigest !== undefined) {
813
967
  if (!this.unownedNative(execution) || await this.host.nativeOwnership?.(state.input) !== 'unowned') {
814
968
  state.nativeUnowned = false;
@@ -824,28 +978,36 @@ export class OnDemandIntake {
824
978
  || !carrier || canonicalContentHash(carrier) !== proof.carrierDigest)
825
979
  throw new Error('Preparation carrier was already consumed or changed');
826
980
  state.preparationCalls.add(execution.callId);
827
- const requestIntent = this.bindIntent(state, carrier.requestIntent);
828
- const clarification = executionClarification(state.task, requestIntent, state.requestIntentContext);
829
- if (clarification)
830
- throw new Error(clarification);
831
- const unresolved = requestIntent?.relation === 'unknown' || requestIntent?.action === 'unknown';
832
- const legacyTaskTypeAllowed = carrier.taskType !== undefined && !state.requestIntentContext?.activeGoal
833
- && (state.requestIntentContext?.proposals.length ?? 0) === 0;
834
- if (unresolved && !legacyTaskTypeAllowed)
835
- throw new Error('Clarify the requested action and scope before preparing work');
836
- const intentType = unresolved ? undefined : requestIntentTaskType(requestIntent, state.taskType ?? state.intent.taskType ?? carrier.taskType);
837
- const taskType = intentType ?? carrier.taskType;
838
- if (carrier.taskType !== undefined && taskType !== carrier.taskType)
839
- throw new Error('The advisory taskType conflicts with the bound request action');
840
- if (!taskType || taskType === 'chat') {
841
- throw new Error('Clarify the requested action and scope before preparing work');
842
- }
843
- state.memoryOwner = memoryOwnerKind(requestIntent, taskType);
844
- await this.prepare(state, taskType, execution.signal, requestIntent, carrier.requestIntent !== undefined);
845
- const result = { prepared: true, taskType: state.taskType, originalTask: state.task,
846
- ...(state.requestIntent ? { requestIntent: state.requestIntent } : {}), memoryOwner: state.memoryOwner,
847
- permissions: 'unchanged_native_policy', programExecuted: false };
848
- return { isError: false, value: { logs: ['Host intake prepared. No program was evaluated.'], result }, content: [{ type: 'text', text: JSON.stringify(result) }] };
981
+ try {
982
+ const requestIntent = this.bindIntent(state, carrier.requestIntent);
983
+ const clarification = executionClarification(state.task, requestIntent, state.requestIntentContext);
984
+ if (clarification)
985
+ throw new RequestClarification(clarification);
986
+ const unresolved = requestIntent !== undefined && requestIntentIsUnresolved(requestIntent);
987
+ const legacyTaskTypeAllowed = carrier.taskType !== undefined && !state.requestIntentContext?.activeGoal && !state.requestIntentContext?.activeTask
988
+ && (state.requestIntentContext?.proposals.length ?? 0) === 0;
989
+ if (unresolved && !legacyTaskTypeAllowed)
990
+ throw new RequestClarification('Clarify the requested action and scope before preparing work');
991
+ const intentType = unresolved ? undefined : requestIntentTaskType(requestIntent, state.taskType ?? state.intent.taskType ?? carrier.taskType);
992
+ const taskType = intentType ?? carrier.taskType;
993
+ if (carrier.taskType !== undefined && taskType !== carrier.taskType)
994
+ throw new Error('The advisory taskType conflicts with the bound request action');
995
+ if (!taskType || taskType === 'chat') {
996
+ throw new RequestClarification('Clarify the requested action and scope before preparing work');
997
+ }
998
+ state.memoryOwner = memoryOwnerKind(requestIntent, taskType);
999
+ await this.prepare(state, taskType, execution.signal, requestIntent, carrier.requestIntent !== undefined);
1000
+ const result = { prepared: true, taskType: state.taskType, originalTask: state.task,
1001
+ ...(state.requestIntent ? { requestIntent: state.requestIntent } : {}), memoryOwner: state.memoryOwner,
1002
+ permissions: 'unchanged_native_policy', programExecuted: false };
1003
+ return { isError: false, value: { logs: ['Host intake prepared. No program was evaluated.'], result }, content: [{ type: 'text', text: JSON.stringify(result) }] };
1004
+ }
1005
+ catch (error) {
1006
+ if (!(error instanceof RequestClarification))
1007
+ throw error;
1008
+ const result = { ...await this.clarify(state, execution.signal, error), programExecuted: false };
1009
+ return { isError: false, value: { logs: ['Awaiting user clarification. No program was evaluated.'], result }, content: [{ type: 'text', text: JSON.stringify(result) }] };
1010
+ }
849
1011
  }, { prepend: true }));
850
1012
  this.#disposers.push(tools.guard(execution => {
851
1013
  if (this.#stopped || execution.signal.aborted)
@@ -870,6 +1032,10 @@ export class OnDemandIntake {
870
1032
  return this.nativeCommand(execution) ? undefined : 'Native command identity or arguments changed';
871
1033
  if (proof.nativeLispDigest !== undefined)
872
1034
  return this.nativeLisp(execution) ? undefined : 'Native Lisp identity or arguments changed';
1035
+ if (proof.evidenceReadDigest !== undefined)
1036
+ return state.status === 'answer' && execution.parent === undefined
1037
+ && NATIVE_EVIDENCE_READS.has(execution.name) && canonicalContentHash(execution.arguments) === proof.evidenceReadDigest
1038
+ ? undefined : 'Native evidence read identity or arguments changed';
873
1039
  if (proof.nativeUnownedDigest !== undefined)
874
1040
  return this.unownedNative(execution) ? undefined : 'Native unowned execution proof changed';
875
1041
  if (proof.carrier)
@@ -899,8 +1065,9 @@ export class OnDemandIntake {
899
1065
  state.controller.abort(new Error('Native agent disposed'));
900
1066
  state.status = 'closed';
901
1067
  this.#turns.delete(agent.session.id);
1068
+ this.#clarifications.delete(agent.session.id);
902
1069
  }
903
- stop() { this.#stopped = true; this.#lifecycle.abort(new Error('On-demand intake stopped')); }
1070
+ stop() { this.#clarifications.clear(); this.#stopped = true; this.#lifecycle.abort(new Error('On-demand intake stopped')); }
904
1071
  async drain() { while (this.#pending.size)
905
1072
  await Promise.allSettled([...this.#pending]); }
906
1073
  async dispose() { this.stop(); await this.drain(); for (const dispose of this.#disposers.reverse())