omk-agent-core 0.98.5 → 1.0.0

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.
@@ -7,8 +7,10 @@ import { bindToolIdentity } from "./builtin-tool-resource-claims.js";
7
7
  import { partitionToolBatchWaves } from "./parallel-tool-batch.js";
8
8
  import { pinProviderConfig, requestAssistantResponse } from "./provider-request.js";
9
9
  export { getVisionRouteModel, isVisionRouteModel, VISION_ROUTE_MODEL } from "./vision-route.js";
10
- import { applyConcurrencyCap, scheduleDagLevels, } from "./tool-dag-scheduler.js";
10
+ import { scheduleDagLevelsMemo as scheduleDagLevelsMemoFromModule } from "./tool-dag-memo.js";
11
+ import { applyConcurrencyCap, assignDagDependencies, conflictsWithUnsettledClaim } from "./tool-dag-scheduler.js";
11
12
  import { awaitWithAbort, createErrorToolResult, createImmutableJsonSnapshot, createImmutableSnapshot, finalizeExecutedToolCall, parseJsonValue, stampToolResultEnvelope, } from "./tool-execution-boundary.js";
13
+ import { resolveToolClaimsForCall } from "./tool-resource-claims.js";
12
14
  import { resolveToolTimeoutMs, runToolCallWithTimeout } from "./tool-timeout.js";
13
15
  import { hasUnsettledTimeout } from "./tool-timeout-settlement.js";
14
16
  import { createSyntheticToolResult, inspectTranscriptIntegrity, repairTranscriptIntegrity, } from "./tool-transcript-integrity.js";
@@ -536,62 +538,6 @@ async function executeToolCallsInWaves(currentContext, assistantMessage, toolCal
536
538
  }
537
539
  return { messages, terminate: terminated, stopRun };
538
540
  }
539
- const DAG_SCHEDULE_CACHE_LIMIT = 64;
540
- /**
541
- * Canonical key covering every input claim resolution depends on. A custom
542
- * `resourceKeyResolver` function cannot be fingerprinted, so callers skip the
543
- * memo entirely when one is configured. Within a run, tool definitions (and
544
- * their `resourceClaims` closures) are stable, so name/mode/claims-presence
545
- * fingerprints are sufficient.
546
- */
547
- function dagScheduleCacheKey(toolCalls, options) {
548
- const policies = [...(options.toolPolicies?.entries() ?? [])].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
549
- const registered = (options.registeredTools ?? []).map((tool) => [
550
- tool.name,
551
- tool.executionMode ?? "",
552
- typeof tool.resourceClaims === "function" ? "1" : "0",
553
- ]);
554
- return JSON.stringify([
555
- toolCalls.map((call) => [call.name, call.arguments ?? null]),
556
- options.cwd,
557
- options.strictExtensionClaims === true,
558
- options.maxConcurrency ?? null,
559
- policies,
560
- registered,
561
- ]);
562
- }
563
- /**
564
- * Schedule with a per-run memo. Identical batches (provider retries, stubborn
565
- * re-emissions) re-resolve path identities and custom claims; the plan is a
566
- * pure function of the canonical inputs, so replaying it is safe. Returns
567
- * `null` when the underlying schedule was aborted. Cached levels are handed
568
- * out as copies because callers append to and reorder them.
569
- */
570
- export async function scheduleDagLevelsMemo(toolCalls, options, signal, cache) {
571
- if (options.resourceKeyResolver) {
572
- const scheduled = await awaitWithAbort(() => scheduleDagLevels(toolCalls, options), signal);
573
- return scheduled.kind === "aborted" ? null : scheduled.value;
574
- }
575
- const key = dagScheduleCacheKey(toolCalls, options);
576
- const cached = cache.get(key);
577
- if (cached) {
578
- cache.delete(key);
579
- cache.set(key, cached);
580
- return { levels: cached.levels.map((level) => level.slice()), planKey: cached.planKey };
581
- }
582
- const scheduled = await awaitWithAbort(() => scheduleDagLevels(toolCalls, options), signal);
583
- if (scheduled.kind === "aborted") {
584
- return null;
585
- }
586
- if (cache.size >= DAG_SCHEDULE_CACHE_LIMIT) {
587
- const oldest = cache.keys().next();
588
- if (!oldest.done) {
589
- cache.delete(oldest.value);
590
- }
591
- }
592
- cache.set(key, { levels: scheduled.value.levels.map((level) => level.slice()), planKey: scheduled.value.planKey });
593
- return scheduled.value;
594
- }
595
541
  /**
596
542
  * Schedule planned calls into candidate DAG levels. Immediate plans fail
597
543
  * before any tool executes, so they carry no claims and fold into the first
@@ -612,7 +558,7 @@ async function schedulePlannedDagLevels(plans, toolPolicies, boundTools, config,
612
558
  immediateSourceIndices.push(sourceIndex);
613
559
  }
614
560
  });
615
- const scheduled = await scheduleDagLevelsMemo(claimableCalls, {
561
+ const scheduled = await scheduleDagLevelsMemoFromModule(claimableCalls, {
616
562
  cwd: config.cwd ?? process.cwd(),
617
563
  toolPolicies,
618
564
  registeredTools: boundTools,
@@ -621,17 +567,29 @@ async function schedulePlannedDagLevels(plans, toolPolicies, boundTools, config,
621
567
  resourceKeyResolver: config.resourceKeyResolver,
622
568
  }, signal, dagScheduleCache);
623
569
  if (scheduled === null) {
624
- return [];
570
+ return { levels: [], resolutions: new Map(), dependencies: new Map() };
625
571
  }
572
+ const resolutions = new Map();
573
+ scheduled.entries.forEach((entry, position) => {
574
+ resolutions.set(schedulableSourceIndices[position], entry.resolution);
575
+ });
576
+ // Precedence graph in source-index space. Immediate plans carry no claims and
577
+ // therefore no predecessors; they already fail before any tool executes.
578
+ const dependencies = new Map();
579
+ for (const sourceIndex of immediateSourceIndices)
580
+ dependencies.set(sourceIndex, []);
581
+ assignDagDependencies(scheduled.entries).forEach((blockers, position) => {
582
+ dependencies.set(schedulableSourceIndices[position], blockers.map((blocker) => schedulableSourceIndices[blocker]));
583
+ });
626
584
  const levels = scheduled.levels.map((level) => level.map((position) => schedulableSourceIndices[position]));
627
585
  if (immediateSourceIndices.length === 0) {
628
- return levels;
586
+ return { levels, resolutions, dependencies };
629
587
  }
630
588
  if (levels.length === 0) {
631
- return [[...immediateSourceIndices]];
589
+ return { levels: [[...immediateSourceIndices]], resolutions, dependencies };
632
590
  }
633
591
  levels[0] = [...levels[0], ...immediateSourceIndices].sort((left, right) => left - right);
634
- return levels;
592
+ return { levels, resolutions, dependencies };
635
593
  }
636
594
  /**
637
595
  * Execute a tool-call batch using the dag-v2 scheduler.
@@ -654,27 +612,25 @@ async function executeToolCallsDagLevels(currentContext, assistantMessage, toolC
654
612
  if (mode && !toolPolicies.has(tool.name))
655
613
  toolPolicies.set(tool.name, mode);
656
614
  }
657
- const levels = await schedulePlannedDagLevels(plans, toolPolicies, boundTools, config, signal, dagScheduleCache);
615
+ const schedule = await schedulePlannedDagLevels(plans, toolPolicies, boundTools, config, signal, dagScheduleCache);
616
+ const levels = schedule.levels;
617
+ const batchState = {
618
+ resolutions: schedule.resolutions,
619
+ deferred: new Map(),
620
+ settled: new Set(),
621
+ };
658
622
  const finalizedByIndex = new Array(toolCalls.length).fill(undefined);
659
623
  let skippedReason;
660
624
  let stoppedByUnsettledTimeout = false;
661
- for (const level of levels) {
662
- if (signal?.aborted)
663
- break;
664
- const executedLevel = await runDagLevelCalls(currentContext, assistantMessage, level, toolCalls, plans, toolPolicies, config, signal, emit, dagScheduleCache);
665
- for (const outcome of executedLevel.outcomes)
666
- finalizedByIndex[outcome.sourceIndex] = outcome.finalized;
667
- if (signal?.aborted)
668
- break;
669
- if (executedLevel.stoppedByUnsettledTimeout) {
670
- skippedReason = "Skipped because a preceding DAG tool timed out before its execution promise settled";
671
- stoppedByUnsettledTimeout = true;
672
- break;
673
- }
674
- if (shouldTerminateToolBatch(executedLevel.outcomes.map((outcome) => outcome.finalized))) {
675
- skippedReason = "Skipped because the preceding DAG level requested termination";
676
- break;
677
- }
625
+ const frontier = await runDagFrontier(currentContext, assistantMessage, levels.flat(), schedule.dependencies, toolCalls, plans, toolPolicies, config, signal, emit, batchState);
626
+ for (const outcome of frontier.outcomes)
627
+ finalizedByIndex[outcome.sourceIndex] = outcome.finalized;
628
+ if (frontier.stoppedByUnsettledTimeout) {
629
+ skippedReason = "Skipped because a preceding DAG tool timed out before its execution promise settled";
630
+ stoppedByUnsettledTimeout = true;
631
+ }
632
+ else if (frontier.terminated) {
633
+ skippedReason = "Skipped because the preceding DAG level requested termination";
678
634
  }
679
635
  const messages = [];
680
636
  const finalizedCalls = [];
@@ -710,85 +666,224 @@ async function executeToolCallsDagLevels(currentContext, assistantMessage, toolC
710
666
  stopRun: stoppedByUnsettledTimeout,
711
667
  };
712
668
  }
713
- /** Re-plan final claims for a runnable candidate level from exact post-hook arguments. */
714
- async function rescheduleRunnableLevels(runnable, toolPolicies, config, signal, dagScheduleCache) {
715
- const finalClaimableCalls = runnable.map(({ preparation }) => ({
716
- id: preparation.toolCall.id,
717
- name: preparation.toolCall.name,
718
- arguments: preparation.args,
719
- }));
720
- const scheduled = await scheduleDagLevelsMemo(finalClaimableCalls, {
721
- cwd: config.cwd ?? process.cwd(),
722
- toolPolicies,
723
- registeredTools: runnable.map(({ preparation }) => preparation.tool),
724
- strictExtensionClaims: config.strictExtensionClaims,
725
- maxConcurrency: config.maxToolConcurrency,
726
- resourceKeyResolver: config.resourceKeyResolver,
727
- }, signal, dagScheduleCache);
728
- return scheduled === null ? null : scheduled.levels;
669
+ // Re-resolve claims for a prepared call from its exact post-hook arguments.
670
+ // Resolution failures fail closed as an immediate error rather than guessing
671
+ // at a safe scope.
672
+ async function resolveFinalResolution(preparation, toolPolicies, config, signal) {
673
+ if (signal?.aborted)
674
+ return immediateOutcome("aborted", "Operation aborted");
675
+ try {
676
+ return await resolveToolClaimsForCall({ id: preparation.toolCall.id, name: preparation.toolCall.name, arguments: preparation.args }, {
677
+ cwd: config.cwd ?? process.cwd(),
678
+ toolPolicies,
679
+ registeredTools: [preparation.tool],
680
+ strictExtensionClaims: config.strictExtensionClaims,
681
+ resourceKeyResolver: config.resourceKeyResolver,
682
+ });
683
+ }
684
+ catch (error) {
685
+ return immediateOutcome("failed", error instanceof Error ? error.message : String(error));
686
+ }
729
687
  }
730
- /** Authorize one candidate DAG level, re-plan final claims, and run its safe sublevels. */
731
- async function runDagLevelCalls(currentContext, assistantMessage, levelIndices, toolCalls, plans, toolPolicies, config, signal, emit, dagScheduleCache) {
688
+ /** No call in the ready queue is pre-ordered against another, so drift checks see no exempt peers. */
689
+ const NO_READY_PEERS = new Set();
690
+ /**
691
+ * Execute a planned batch as a dependency ready queue.
692
+ *
693
+ * States are `pending -> ready -> running -> settled`. A call is admitted once
694
+ * every call it actually conflicts with has settled, rather than once its whole
695
+ * barrier level has, so one slow unrelated call no longer delays independent
696
+ * work queued behind it.
697
+ *
698
+ * Admission — authorize, re-resolve claims from exact post-hook arguments, and
699
+ * enforce the final-claims contract — runs one call at a time in source order.
700
+ * That keeps hook ordering and the conflict contract identical to the barrier
701
+ * executor and leaves the shared batch state free of concurrent mutation; only
702
+ * tool execution overlaps. Simultaneously-ready calls are admitted by source
703
+ * index, so admission order stays deterministic even though completion order
704
+ * is not.
705
+ *
706
+ * A call whose post-hook claims newly conflict with an earlier unsettled call
707
+ * yields its turn and is retried after the next settle, which preserves the
708
+ * source-order conflict contract without a separate drain pass.
709
+ */
710
+ async function runDagFrontier(currentContext, assistantMessage, order, dependencies, toolCalls, plans, toolPolicies, config, signal, emit, state) {
732
711
  const outcomes = [];
733
- const runnable = [];
734
- for (const sourceIndex of levelIndices) {
735
- const toolCall = toolCalls[sourceIndex];
736
- const plan = plans[sourceIndex];
737
- const preparation = plan.kind === "immediate"
738
- ? plan
739
- : await authorizePlannedToolCall(currentContext, assistantMessage, plan, config, signal);
740
- if (preparation.kind === "immediate") {
741
- outcomes.push({
742
- sourceIndex,
743
- finalized: {
744
- toolCall,
745
- result: preparation.result,
746
- isError: preparation.isError,
747
- envelope: preparation.envelope,
748
- },
749
- });
750
- }
751
- else {
752
- runnable.push({ sourceIndex, preparation });
712
+ const pending = [...order].sort((left, right) => left - right);
713
+ const running = new Map();
714
+ // Mirrors applyConcurrencyCap: absent, non-finite, or non-positive is unbounded.
715
+ const cap = typeof config.maxToolConcurrency === "number" &&
716
+ Number.isFinite(config.maxToolConcurrency) &&
717
+ config.maxToolConcurrency > 0
718
+ ? Math.max(1, Math.floor(config.maxToolConcurrency))
719
+ : Number.POSITIVE_INFINITY;
720
+ let stoppedByUnsettledTimeout = false;
721
+ let terminated = false;
722
+ // Readiness bookkeeping: each pending call tracks how many of its
723
+ // predecessors are still unsettled, and each settle decrements only its
724
+ // successors — the per-settle cost is proportional to the out-degree of the
725
+ // finished call, not to a full rescan of every pending dependency list.
726
+ const remainingPredecessors = new Map();
727
+ const successors = new Map();
728
+ for (const sourceIndex of pending) {
729
+ const blockers = dependencies.get(sourceIndex) ?? [];
730
+ remainingPredecessors.set(sourceIndex, blockers.filter((blocker) => !state.settled.has(blocker)).length);
731
+ for (const blocker of blockers) {
732
+ const followers = successors.get(blocker);
733
+ if (followers)
734
+ followers.push(sourceIndex);
735
+ else
736
+ successors.set(blocker, [sourceIndex]);
753
737
  }
754
- if (signal?.aborted)
755
- return { outcomes, stoppedByUnsettledTimeout: false };
756
738
  }
757
- // Re-plan final claims from the exact post-hook arguments. Non-plain
758
- // payloads stay in the schedule and fail closed into exclusive barriers
759
- // inside claim resolution rather than degrading the level to sequential
760
- // singletons.
761
- const executionLevels = await rescheduleRunnableLevels(runnable, toolPolicies, config, signal, dagScheduleCache);
762
- if (executionLevels === null)
763
- return { outcomes, stoppedByUnsettledTimeout: false };
764
- for (const executionLevel of executionLevels) {
765
- if (signal?.aborted)
766
- break;
767
- for (const entryIndex of executionLevel) {
768
- await emitToolExecutionStart(runnable[entryIndex].preparation, emit);
739
+ const settle = async (outcome) => {
740
+ outcomes.push(outcome);
741
+ state.settled.add(outcome.sourceIndex);
742
+ for (const follower of successors.get(outcome.sourceIndex) ?? []) {
743
+ const remaining = (remainingPredecessors.get(follower) ?? 0) - 1;
744
+ remainingPredecessors.set(follower, Math.max(0, remaining));
769
745
  }
770
- const finalizedLevel = await Promise.all(executionLevel.map(async (entryIndex) => {
771
- const entry = runnable[entryIndex];
772
- const executed = await executePreparedToolCall(entry.preparation, config, signal, emit);
773
- const finalized = await finalizeExecutedToolCall({
774
- currentContext,
775
- assistantMessage,
776
- prepared: entry.preparation,
777
- executed,
778
- afterToolCall: config.afterToolCall,
779
- signal,
780
- });
781
- await emitToolExecutionEnd(finalized, emit);
782
- return { sourceIndex: entry.sourceIndex, finalized };
783
- }));
784
- outcomes.push(...finalizedLevel);
785
- if (signal?.aborted)
786
- break;
787
- if (await hasUnsettledTimeout(finalizedLevel.map(({ finalized }) => finalized))) {
788
- return { outcomes, stoppedByUnsettledTimeout: true };
746
+ if (await hasUnsettledTimeout([outcome.finalized]))
747
+ stoppedByUnsettledTimeout = true;
748
+ };
749
+ try {
750
+ while (!signal?.aborted && !stoppedByUnsettledTimeout && !terminated) {
751
+ // Termination is a property of a settled group, not of one call:
752
+ // shouldTerminateToolBatch requires every member to terminate. Evaluating it
753
+ // only when nothing is in flight keeps the barrier executor's semantics,
754
+ // where a call sharing a level with a non-terminating peer did not end the
755
+ // batch. Judging a single outcome would terminate on the first terminating
756
+ // call and strand its concurrent peers.
757
+ if (running.size === 0 && shouldTerminateToolBatch(outcomes.map((outcome) => outcome.finalized))) {
758
+ terminated = true;
759
+ break;
760
+ }
761
+ let admitted = false;
762
+ for (let position = 0; position < pending.length && running.size < cap;) {
763
+ if (signal?.aborted || stoppedByUnsettledTimeout)
764
+ break;
765
+ const sourceIndex = pending[position];
766
+ if ((remainingPredecessors.get(sourceIndex) ?? 0) > 0) {
767
+ position++;
768
+ continue;
769
+ }
770
+ const cached = state.deferred.get(sourceIndex);
771
+ let preparation;
772
+ let resolved;
773
+ if (cached) {
774
+ preparation = cached.preparation;
775
+ resolved = cached.resolution;
776
+ }
777
+ else {
778
+ const plan = plans[sourceIndex];
779
+ const preparedOutcome = plan.kind === "immediate"
780
+ ? plan
781
+ : await authorizePlannedToolCall(currentContext, assistantMessage, plan, config, signal);
782
+ if (preparedOutcome.kind === "immediate") {
783
+ pending.splice(position, 1);
784
+ admitted = true;
785
+ await settle({
786
+ sourceIndex,
787
+ finalized: {
788
+ toolCall: toolCalls[sourceIndex],
789
+ result: preparedOutcome.result,
790
+ isError: preparedOutcome.isError,
791
+ envelope: preparedOutcome.envelope,
792
+ },
793
+ });
794
+ continue;
795
+ }
796
+ preparation = preparedOutcome;
797
+ const resolution = await resolveFinalResolution(preparation, toolPolicies, config, signal);
798
+ if (resolution.kind === "immediate") {
799
+ pending.splice(position, 1);
800
+ admitted = true;
801
+ await settle({
802
+ sourceIndex,
803
+ finalized: {
804
+ toolCall: preparation.toolCall,
805
+ result: resolution.result,
806
+ isError: resolution.isError,
807
+ envelope: resolution.envelope,
808
+ },
809
+ });
810
+ continue;
811
+ }
812
+ resolved = resolution;
813
+ }
814
+ state.resolutions.set(sourceIndex, resolved);
815
+ if (conflictsWithUnsettledClaim(sourceIndex, resolved, NO_READY_PEERS, state.settled, new Set(running.keys()), state.resolutions)) {
816
+ // Defer without losing the prepared call: a later scan retries
817
+ // admission from this cache instead of re-invoking authorization.
818
+ if (!cached)
819
+ state.deferred.set(sourceIndex, { preparation, resolution: resolved });
820
+ position++;
821
+ continue;
822
+ }
823
+ state.deferred.delete(sourceIndex);
824
+ pending.splice(position, 1);
825
+ admitted = true;
826
+ await emitToolExecutionStart(preparation, emit);
827
+ running.set(sourceIndex, (async () => {
828
+ const executed = await executePreparedToolCall(preparation, config, signal, emit);
829
+ const finalized = await finalizeExecutedToolCall({
830
+ currentContext,
831
+ assistantMessage,
832
+ prepared: preparation,
833
+ executed,
834
+ afterToolCall: config.afterToolCall,
835
+ signal,
836
+ });
837
+ await emitToolExecutionEnd(finalized, emit);
838
+ return { sourceIndex, finalized };
839
+ })());
840
+ }
841
+ if (running.size === 0) {
842
+ // Nothing is in flight: either every call settled, or the remainder is
843
+ // blocked with nothing left that could unblock it. A pending call that
844
+ // can never become ready still gets an explicit terminal outcome rather
845
+ // than vanishing from the batch.
846
+ if (!admitted) {
847
+ for (const sourceIndex of pending.splice(0)) {
848
+ await settle({
849
+ sourceIndex,
850
+ finalized: {
851
+ toolCall: toolCalls[sourceIndex],
852
+ result: createErrorToolResult("DAG dependency deadlock: call could not become ready and nothing is running to unblock it"),
853
+ isError: true,
854
+ envelope: createToolResultEnvelope({
855
+ disposition: "failed",
856
+ synthetic: true,
857
+ executionStarted: false,
858
+ reason: "DAG dependency deadlock",
859
+ }),
860
+ },
861
+ });
862
+ }
863
+ break;
864
+ }
865
+ continue;
866
+ }
867
+ const finished = await Promise.race([...running.values()]);
868
+ running.delete(finished.sourceIndex);
869
+ await settle(finished);
870
+ }
871
+ }
872
+ finally {
873
+ // Join work already in flight: cancellation, termination, and propagation
874
+ // of preparation/event errors all preserve ownership of started
875
+ // executions rather than abandoning them mid-flight.
876
+ while (running.size > 0) {
877
+ const entries = [...running.entries()];
878
+ const drained = await Promise.allSettled(entries.map(([, task]) => task));
879
+ for (const [index, drainedResult] of drained.entries()) {
880
+ running.delete(entries[index][0]);
881
+ if (drainedResult.status === "fulfilled")
882
+ await settle(drainedResult.value);
883
+ }
789
884
  }
790
885
  }
791
- return { outcomes, stoppedByUnsettledTimeout: false };
886
+ return { outcomes, stoppedByUnsettledTimeout, terminated };
792
887
  }
793
888
  async function executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit) {
794
889
  const finalizedCalls = [];