omk-agent-core 0.99.0 → 1.2.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,236 @@ 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, {
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
+ //
673
+ // The wait is bound to the run's abort signal exactly like the initial
674
+ // scheduling pass (tool-dag-memo): an extension `resourceClaims()` that never
675
+ // settles must not pin a cancelled run. Abandoning the wait does not stop the
676
+ // callback a plain Promise cannot be killed — but the aborted outcome settles
677
+ // this call before any late value could admit it, and the frontier loop exits
678
+ // on the same signal, so a late fulfilment or rejection lands in a finished
679
+ // batch and admits nothing.
680
+ async function resolveFinalResolution(preparation, toolPolicies, config, signal) {
681
+ // awaitWithAbort short-circuits an already-aborted signal before invoking the callback.
682
+ const call = { id: preparation.toolCall.id, name: preparation.toolCall.name, arguments: preparation.args };
683
+ const options = {
721
684
  cwd: config.cwd ?? process.cwd(),
722
685
  toolPolicies,
723
- registeredTools: runnable.map(({ preparation }) => preparation.tool),
686
+ registeredTools: [preparation.tool],
724
687
  strictExtensionClaims: config.strictExtensionClaims,
725
- maxConcurrency: config.maxToolConcurrency,
726
688
  resourceKeyResolver: config.resourceKeyResolver,
727
- }, signal, dagScheduleCache);
728
- return scheduled === null ? null : scheduled.levels;
689
+ };
690
+ try {
691
+ const bounded = await awaitWithAbort(() => resolveToolClaimsForCall(call, options), signal);
692
+ if (bounded.kind === "aborted" || signal?.aborted)
693
+ return immediateOutcome("aborted", "Operation aborted");
694
+ return bounded.value;
695
+ }
696
+ catch (error) {
697
+ return immediateOutcome("failed", error instanceof Error ? error.message : String(error));
698
+ }
729
699
  }
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) {
700
+ /** No call in the ready queue is pre-ordered against another, so drift checks see no exempt peers. */
701
+ const NO_READY_PEERS = new Set();
702
+ /**
703
+ * Execute a planned batch as a dependency ready queue.
704
+ *
705
+ * States are `pending -> ready -> running -> settled`. A call is admitted once
706
+ * every call it actually conflicts with has settled, rather than once its whole
707
+ * barrier level has, so one slow unrelated call no longer delays independent
708
+ * work queued behind it.
709
+ *
710
+ * Admission — authorize, re-resolve claims from exact post-hook arguments, and
711
+ * enforce the final-claims contract — runs one call at a time in source order.
712
+ * That keeps hook ordering and the conflict contract identical to the barrier
713
+ * executor and leaves the shared batch state free of concurrent mutation; only
714
+ * tool execution overlaps. Simultaneously-ready calls are admitted by source
715
+ * index, so admission order stays deterministic even though completion order
716
+ * is not.
717
+ *
718
+ * A call whose post-hook claims newly conflict with an earlier unsettled call
719
+ * yields its turn and is retried after the next settle, which preserves the
720
+ * source-order conflict contract without a separate drain pass.
721
+ */
722
+ async function runDagFrontier(currentContext, assistantMessage, order, dependencies, toolCalls, plans, toolPolicies, config, signal, emit, state) {
732
723
  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 });
724
+ const pending = [...order].sort((left, right) => left - right);
725
+ const running = new Map();
726
+ // Mirrors applyConcurrencyCap: absent, non-finite, or non-positive is unbounded.
727
+ const cap = typeof config.maxToolConcurrency === "number" &&
728
+ Number.isFinite(config.maxToolConcurrency) &&
729
+ config.maxToolConcurrency > 0
730
+ ? Math.max(1, Math.floor(config.maxToolConcurrency))
731
+ : Number.POSITIVE_INFINITY;
732
+ let stoppedByUnsettledTimeout = false;
733
+ let terminated = false;
734
+ // Readiness bookkeeping: each pending call tracks how many of its
735
+ // predecessors are still unsettled, and each settle decrements only its
736
+ // successors — the per-settle cost is proportional to the out-degree of the
737
+ // finished call, not to a full rescan of every pending dependency list.
738
+ const remainingPredecessors = new Map();
739
+ const successors = new Map();
740
+ for (const sourceIndex of pending) {
741
+ const blockers = dependencies.get(sourceIndex) ?? [];
742
+ remainingPredecessors.set(sourceIndex, blockers.filter((blocker) => !state.settled.has(blocker)).length);
743
+ for (const blocker of blockers) {
744
+ const followers = successors.get(blocker);
745
+ if (followers)
746
+ followers.push(sourceIndex);
747
+ else
748
+ successors.set(blocker, [sourceIndex]);
753
749
  }
754
- if (signal?.aborted)
755
- return { outcomes, stoppedByUnsettledTimeout: false };
756
750
  }
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);
751
+ const settle = async (outcome) => {
752
+ outcomes.push(outcome);
753
+ state.settled.add(outcome.sourceIndex);
754
+ for (const follower of successors.get(outcome.sourceIndex) ?? []) {
755
+ const remaining = (remainingPredecessors.get(follower) ?? 0) - 1;
756
+ remainingPredecessors.set(follower, Math.max(0, remaining));
769
757
  }
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 };
758
+ if (await hasUnsettledTimeout([outcome.finalized]))
759
+ stoppedByUnsettledTimeout = true;
760
+ };
761
+ try {
762
+ while (!signal?.aborted && !stoppedByUnsettledTimeout && !terminated) {
763
+ // Termination is a property of a settled group, not of one call:
764
+ // shouldTerminateToolBatch requires every member to terminate. Evaluating it
765
+ // only when nothing is in flight keeps the barrier executor's semantics,
766
+ // where a call sharing a level with a non-terminating peer did not end the
767
+ // batch. Judging a single outcome would terminate on the first terminating
768
+ // call and strand its concurrent peers.
769
+ if (running.size === 0 && shouldTerminateToolBatch(outcomes.map((outcome) => outcome.finalized))) {
770
+ terminated = true;
771
+ break;
772
+ }
773
+ let admitted = false;
774
+ for (let position = 0; position < pending.length && running.size < cap;) {
775
+ if (signal?.aborted || stoppedByUnsettledTimeout)
776
+ break;
777
+ const sourceIndex = pending[position];
778
+ if ((remainingPredecessors.get(sourceIndex) ?? 0) > 0) {
779
+ position++;
780
+ continue;
781
+ }
782
+ const cached = state.deferred.get(sourceIndex);
783
+ let preparation;
784
+ let resolved;
785
+ if (cached) {
786
+ preparation = cached.preparation;
787
+ resolved = cached.resolution;
788
+ }
789
+ else {
790
+ const plan = plans[sourceIndex];
791
+ const preparedOutcome = plan.kind === "immediate"
792
+ ? plan
793
+ : await authorizePlannedToolCall(currentContext, assistantMessage, plan, config, signal);
794
+ if (preparedOutcome.kind === "immediate") {
795
+ pending.splice(position, 1);
796
+ admitted = true;
797
+ await settle({
798
+ sourceIndex,
799
+ finalized: {
800
+ toolCall: toolCalls[sourceIndex],
801
+ result: preparedOutcome.result,
802
+ isError: preparedOutcome.isError,
803
+ envelope: preparedOutcome.envelope,
804
+ },
805
+ });
806
+ continue;
807
+ }
808
+ preparation = preparedOutcome;
809
+ const resolution = await resolveFinalResolution(preparation, toolPolicies, config, signal);
810
+ if (resolution.kind === "immediate") {
811
+ pending.splice(position, 1);
812
+ admitted = true;
813
+ await settle({
814
+ sourceIndex,
815
+ finalized: {
816
+ toolCall: preparation.toolCall,
817
+ result: resolution.result,
818
+ isError: resolution.isError,
819
+ envelope: resolution.envelope,
820
+ },
821
+ });
822
+ continue;
823
+ }
824
+ resolved = resolution;
825
+ }
826
+ state.resolutions.set(sourceIndex, resolved);
827
+ if (conflictsWithUnsettledClaim(sourceIndex, resolved, NO_READY_PEERS, state.settled, new Set(running.keys()), state.resolutions)) {
828
+ // Defer without losing the prepared call: a later scan retries
829
+ // admission from this cache instead of re-invoking authorization.
830
+ if (!cached)
831
+ state.deferred.set(sourceIndex, { preparation, resolution: resolved });
832
+ position++;
833
+ continue;
834
+ }
835
+ state.deferred.delete(sourceIndex);
836
+ pending.splice(position, 1);
837
+ admitted = true;
838
+ await emitToolExecutionStart(preparation, emit);
839
+ running.set(sourceIndex, (async () => {
840
+ const executed = await executePreparedToolCall(preparation, config, signal, emit);
841
+ const finalized = await finalizeExecutedToolCall({
842
+ currentContext,
843
+ assistantMessage,
844
+ prepared: preparation,
845
+ executed,
846
+ afterToolCall: config.afterToolCall,
847
+ signal,
848
+ });
849
+ await emitToolExecutionEnd(finalized, emit);
850
+ return { sourceIndex, finalized };
851
+ })());
852
+ }
853
+ if (running.size === 0) {
854
+ // Nothing is in flight: either every call settled, or the remainder is
855
+ // blocked with nothing left that could unblock it. A pending call that
856
+ // can never become ready still gets an explicit terminal outcome rather
857
+ // than vanishing from the batch.
858
+ if (!admitted) {
859
+ for (const sourceIndex of pending.splice(0)) {
860
+ await settle({
861
+ sourceIndex,
862
+ finalized: {
863
+ toolCall: toolCalls[sourceIndex],
864
+ result: createErrorToolResult("DAG dependency deadlock: call could not become ready and nothing is running to unblock it"),
865
+ isError: true,
866
+ envelope: createToolResultEnvelope({
867
+ disposition: "failed",
868
+ synthetic: true,
869
+ executionStarted: false,
870
+ reason: "DAG dependency deadlock",
871
+ }),
872
+ },
873
+ });
874
+ }
875
+ break;
876
+ }
877
+ continue;
878
+ }
879
+ const finished = await Promise.race([...running.values()]);
880
+ running.delete(finished.sourceIndex);
881
+ await settle(finished);
882
+ }
883
+ }
884
+ finally {
885
+ // Join work already in flight: cancellation, termination, and propagation
886
+ // of preparation/event errors all preserve ownership of started
887
+ // executions rather than abandoning them mid-flight.
888
+ while (running.size > 0) {
889
+ const entries = [...running.entries()];
890
+ const drained = await Promise.allSettled(entries.map(([, task]) => task));
891
+ for (const [index, drainedResult] of drained.entries()) {
892
+ running.delete(entries[index][0]);
893
+ if (drainedResult.status === "fulfilled")
894
+ await settle(drainedResult.value);
895
+ }
789
896
  }
790
897
  }
791
- return { outcomes, stoppedByUnsettledTimeout: false };
898
+ return { outcomes, stoppedByUnsettledTimeout, terminated };
792
899
  }
793
900
  async function executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit) {
794
901
  const finalizedCalls = [];