nextloop 0.1.0rc1__py3-none-any.whl

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.
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable, Sequence
5
+ from dataclasses import dataclass
6
+
7
+ from ..contracts import (
8
+ Cancelled,
9
+ Completed,
10
+ EventType,
11
+ NewEvent,
12
+ Skipped,
13
+ ToolCall,
14
+ ToolOutcome,
15
+ ToolRuntime,
16
+ ToolSpec,
17
+ UncertainHanging,
18
+ UncertainSettled,
19
+ ValidatedCall,
20
+ ValidationIssue,
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class BatchResult:
26
+ outcomes: tuple[tuple[ToolCall, ToolSpec, ToolOutcome], ...] = ()
27
+ issues: tuple[ValidationIssue, ...] = ()
28
+ blocked_resources: frozenset[str] = frozenset()
29
+
30
+
31
+ class BatchExecutor:
32
+ def __init__(self, registry: ToolRuntime, max_parallel: int) -> None:
33
+ self.registry = registry
34
+ self.max_parallel = max_parallel
35
+
36
+ async def execute(
37
+ self,
38
+ calls: Sequence[ToolCall],
39
+ *,
40
+ session_id: str,
41
+ turn: int,
42
+ step: int,
43
+ cancel: asyncio.Event,
44
+ active_resources: frozenset[str],
45
+ commit: Callable[[Sequence[NewEvent]], Awaitable[None]],
46
+ ) -> BatchResult:
47
+ validated, issues = self.registry.validate(calls)
48
+ if issues:
49
+ return BatchResult(issues=issues)
50
+ blocked = frozenset(
51
+ resource
52
+ for item in validated
53
+ for resource in item.spec.resources
54
+ if resource in active_resources
55
+ )
56
+ if blocked:
57
+ return BatchResult(
58
+ outcomes=tuple(
59
+ (
60
+ item.call,
61
+ item.spec,
62
+ Skipped("Blocked by unresolved resources: " + ", ".join(sorted(blocked))),
63
+ )
64
+ for item in validated
65
+ ),
66
+ blocked_resources=blocked,
67
+ )
68
+
69
+ outcomes: list[tuple[ToolCall, ToolSpec, ToolOutcome]] = []
70
+ index = 0
71
+ truncated = False
72
+ while index < len(validated):
73
+ if cancel.is_set() or truncated:
74
+ outcomes.extend(
75
+ self._skip(validated[index:], "Cancelled or truncated before dispatch")
76
+ )
77
+ break
78
+ current = validated[index]
79
+ if current.spec.mode.value == "exclusive":
80
+ outcome = await self._run_one(current, session_id, turn, step, cancel, commit)
81
+ outcomes.append((current.call, current.spec, outcome))
82
+ index += 1
83
+ if self._closes_batch(current.spec, outcome):
84
+ outcomes.extend(
85
+ self._skip(validated[index:], "Truncated after exclusive Tool outcome")
86
+ )
87
+ break
88
+ continue
89
+
90
+ group = []
91
+ while index < len(validated) and validated[index].spec.mode.value == "parallel":
92
+ group.append(validated[index])
93
+ index += 1
94
+ for start in range(0, len(group), self.max_parallel):
95
+ chunk = group[start : start + self.max_parallel]
96
+ if cancel.is_set() or truncated:
97
+ outcomes.extend(
98
+ self._skip(chunk, "Cancelled or truncated before parallel dispatch")
99
+ )
100
+ continue
101
+ chunk_outcomes = await asyncio.gather(
102
+ *(self._run_one(item, session_id, turn, step, cancel, commit) for item in chunk)
103
+ )
104
+ outcomes.extend(
105
+ (item.call, item.spec, outcome)
106
+ for item, outcome in zip(chunk, chunk_outcomes, strict=True)
107
+ )
108
+ if any(self._is_uncertain_or_cancelled(outcome) for outcome in chunk_outcomes):
109
+ truncated = True
110
+ outcomes.extend(
111
+ self._skip(
112
+ group[start + len(chunk) :],
113
+ "Truncated after uncertain parallel outcome",
114
+ )
115
+ )
116
+ return BatchResult(outcomes=tuple(outcomes))
117
+
118
+ async def _run_one(
119
+ self,
120
+ item: ValidatedCall,
121
+ session_id: str,
122
+ turn: int,
123
+ step: int,
124
+ cancel: asyncio.Event,
125
+ commit: Callable[[Sequence[NewEvent]], Awaitable[None]],
126
+ ) -> ToolOutcome:
127
+ await commit(
128
+ [
129
+ NewEvent(
130
+ event_type=EventType.TOOL_CALL,
131
+ turn=turn,
132
+ step=step,
133
+ payload={
134
+ "call_id": item.call.call_id,
135
+ "name": item.call.name,
136
+ "arguments": item.call.arguments,
137
+ "resources": list(item.spec.resources),
138
+ },
139
+ )
140
+ ]
141
+ )
142
+ return await self.registry.execute(
143
+ item.call,
144
+ item.arguments,
145
+ session_id=session_id,
146
+ turn=turn,
147
+ step=step,
148
+ cancel=cancel,
149
+ )
150
+
151
+ @staticmethod
152
+ def _skip(
153
+ items: Sequence[ValidatedCall], reason: str
154
+ ) -> list[tuple[ToolCall, ToolSpec, ToolOutcome]]:
155
+ return [(item.call, item.spec, Skipped(reason)) for item in items]
156
+
157
+ @staticmethod
158
+ def _is_uncertain_or_cancelled(outcome: ToolOutcome) -> bool:
159
+ return isinstance(outcome, (Cancelled, UncertainSettled, UncertainHanging))
160
+
161
+ @classmethod
162
+ def _closes_batch(cls, spec: ToolSpec, outcome: ToolOutcome) -> bool:
163
+ if cls._is_uncertain_or_cancelled(outcome):
164
+ return True
165
+ return isinstance(outcome, Completed) and (outcome.is_error or outcome.concludes_turn)
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+ from dataclasses import dataclass
6
+
7
+ from ..contracts import (
8
+ CandidateRequest,
9
+ EventType,
10
+ LoopConfig,
11
+ ModelProvider,
12
+ ModelResponse,
13
+ NewEvent,
14
+ ProviderError,
15
+ )
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ModelCallResult:
20
+ events: tuple[NewEvent, ...]
21
+ response: ModelResponse | None = None
22
+ error: ProviderError | None = None
23
+ cancelled: bool = False
24
+ rebuild_for_steer: bool = False
25
+
26
+
27
+ class ModelCaller:
28
+ def __init__(
29
+ self,
30
+ provider: ModelProvider,
31
+ config: LoopConfig,
32
+ has_steer: Callable[[], Awaitable[bool]],
33
+ ) -> None:
34
+ self.provider = provider
35
+ self.config = config
36
+ self.has_steer = has_steer
37
+
38
+ async def call(self, request: CandidateRequest, cancel: asyncio.Event) -> ModelCallResult:
39
+ events: list[NewEvent] = []
40
+ for attempt in range(1, self.config.max_provider_attempts + 1):
41
+ if cancel.is_set():
42
+ return ModelCallResult(events=tuple(events), cancelled=True)
43
+ events.append(self._event(request, attempt, "started"))
44
+ try:
45
+ response = await _await_with_cancel(
46
+ self.provider.complete(request, attempt=attempt, cancel=cancel),
47
+ cancel,
48
+ )
49
+ except _CancelledByUser:
50
+ events.append(self._event(request, attempt, "cancelled"))
51
+ return ModelCallResult(events=tuple(events), cancelled=True)
52
+ except ProviderError as exc:
53
+ events.append(
54
+ self._event(
55
+ request,
56
+ attempt,
57
+ "error",
58
+ error=str(exc),
59
+ retryable=exc.retryable,
60
+ partial_output=exc.partial_output,
61
+ code=exc.code,
62
+ )
63
+ )
64
+ if cancel.is_set():
65
+ return ModelCallResult(events=tuple(events), cancelled=True)
66
+ if (
67
+ exc.partial_output
68
+ or not exc.retryable
69
+ or attempt >= self.config.max_provider_attempts
70
+ ):
71
+ return ModelCallResult(events=tuple(events), error=exc)
72
+ if await self.has_steer():
73
+ return ModelCallResult(events=tuple(events), rebuild_for_steer=True)
74
+ delay = self.config.retry_backoff_seconds * (2 ** (attempt - 1))
75
+ if delay and await _sleep_or_cancel(delay, cancel):
76
+ return ModelCallResult(events=tuple(events), cancelled=True)
77
+ continue
78
+ except asyncio.CancelledError:
79
+ events.append(self._event(request, attempt, "cancelled"))
80
+ return ModelCallResult(events=tuple(events), cancelled=True)
81
+ except Exception as exc:
82
+ error = ProviderError(f"{type(exc).__name__}: {exc}")
83
+ events.append(self._event(request, attempt, "error", error=str(error)))
84
+ return ModelCallResult(events=tuple(events), error=error)
85
+ events.append(self._event(request, attempt, "success"))
86
+ return ModelCallResult(events=tuple(events), response=response)
87
+ return ModelCallResult(
88
+ events=tuple(events),
89
+ error=ProviderError("Provider retry loop exited without a response"),
90
+ )
91
+
92
+ @staticmethod
93
+ def _event(request: CandidateRequest, attempt: int, status: str, **payload: object) -> NewEvent:
94
+ return NewEvent(
95
+ event_type=EventType.ASSISTANT_ATTEMPT,
96
+ turn=request.turn,
97
+ step=request.step,
98
+ attempt=attempt,
99
+ payload={"status": status, **payload},
100
+ )
101
+
102
+
103
+ class _CancelledByUser(Exception):
104
+ pass
105
+
106
+
107
+ async def _await_with_cancel(
108
+ awaitable: Awaitable[ModelResponse], cancel: asyncio.Event
109
+ ) -> ModelResponse:
110
+ if cancel.is_set():
111
+ raise _CancelledByUser()
112
+ operation = asyncio.ensure_future(awaitable)
113
+ cancellation = asyncio.create_task(cancel.wait())
114
+ try:
115
+ done, _ = await asyncio.wait({operation, cancellation}, return_when=asyncio.FIRST_COMPLETED)
116
+ if cancellation in done and not operation.done():
117
+ operation.cancel()
118
+ await asyncio.gather(operation, return_exceptions=True)
119
+ raise _CancelledByUser()
120
+ cancellation.cancel()
121
+ return await operation
122
+ finally:
123
+ cancellation.cancel()
124
+ if not operation.done():
125
+ operation.cancel()
126
+ await asyncio.gather(operation, cancellation, return_exceptions=True)
127
+
128
+
129
+ async def _sleep_or_cancel(delay: float, cancel: asyncio.Event) -> bool:
130
+ try:
131
+ await asyncio.wait_for(cancel.wait(), timeout=delay)
132
+ return True
133
+ except TimeoutError:
134
+ return cancel.is_set()
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import jsonschema
11
+
12
+ from ..contracts import (
13
+ Cancelled,
14
+ Completed,
15
+ ToolCall,
16
+ ToolOutcome,
17
+ ToolSpec,
18
+ UncertainHanging,
19
+ ValidatedCall,
20
+ ValidationIssue,
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ToolContext:
26
+ session_id: str
27
+ turn: int
28
+ step: int
29
+ cancel: asyncio.Event
30
+
31
+
32
+ ToolHandler = Callable[[Mapping[str, Any], ToolContext], Any]
33
+
34
+
35
+ class ToolRegistry:
36
+ def __init__(self) -> None:
37
+ self._specs: dict[str, ToolSpec] = {}
38
+ self._handlers: dict[str, ToolHandler] = {}
39
+
40
+ def register(self, spec: ToolSpec, handler: ToolHandler) -> None:
41
+ if spec.name in self._specs:
42
+ raise ValueError(f"Duplicate Tool name: {spec.name}")
43
+ self._specs[spec.name] = spec
44
+ self._handlers[spec.name] = handler
45
+
46
+ def catalog(self) -> Sequence[ToolSpec]:
47
+ return tuple(self._specs.values())
48
+
49
+ def validate(
50
+ self, calls: Sequence[ToolCall]
51
+ ) -> tuple[tuple[ValidatedCall, ...], tuple[ValidationIssue, ...]]:
52
+ validated = []
53
+ issues = []
54
+ seen_ids: set[str] = set()
55
+ for index, call in enumerate(calls):
56
+ call_id = call.call_id or f"unidentified_call_{index}"
57
+ if not call.call_id:
58
+ issues.append(ValidationIssue(call_id, call.name, "Tool Call identity is empty"))
59
+ elif call.call_id in seen_ids:
60
+ issues.append(
61
+ ValidationIssue(call_id, call.name, "Tool Call identity is duplicated")
62
+ )
63
+ seen_ids.add(call.call_id)
64
+ spec = self._specs.get(call.name)
65
+ if spec is None:
66
+ issues.append(ValidationIssue(call_id, call.name, "Unknown Tool name"))
67
+ continue
68
+ try:
69
+ arguments = json.loads(call.arguments)
70
+ except (json.JSONDecodeError, TypeError) as exc:
71
+ issues.append(ValidationIssue(call_id, call.name, f"Invalid argument JSON: {exc}"))
72
+ continue
73
+ if not isinstance(arguments, dict):
74
+ issues.append(
75
+ ValidationIssue(call_id, call.name, "Tool arguments must be a JSON object")
76
+ )
77
+ continue
78
+ validator = jsonschema.Draft202012Validator(spec.parameters)
79
+ for error in sorted(validator.iter_errors(arguments), key=lambda item: list(item.path)):
80
+ path = "$" + "".join(
81
+ f"[{part}]" if isinstance(part, int) else f".{part}" for part in error.path
82
+ )
83
+ issues.append(ValidationIssue(call_id, call.name, f"{path}: {error.message}"))
84
+ validated.append(ValidatedCall(call=call, spec=spec, arguments=arguments))
85
+ return tuple(validated), tuple(issues)
86
+
87
+ async def execute(
88
+ self,
89
+ call: ToolCall,
90
+ arguments: Mapping[str, Any],
91
+ *,
92
+ session_id: str,
93
+ turn: int,
94
+ step: int,
95
+ cancel: asyncio.Event,
96
+ ) -> ToolOutcome:
97
+ spec = self._specs[call.name]
98
+ if cancel.is_set():
99
+ return Cancelled(
100
+ settled=True, partial_content="Cancelled before Tool handler execution"
101
+ )
102
+ handler = self._handlers[call.name]
103
+ try:
104
+ operation = handler(arguments, ToolContext(session_id, turn, step, cancel))
105
+ if inspect.isawaitable(operation):
106
+ cancellable = _await_with_cancel(operation, cancel)
107
+ if spec.timeout_seconds is not None:
108
+ cancellable = asyncio.wait_for(cancellable, timeout=spec.timeout_seconds)
109
+ value = await cancellable
110
+ else:
111
+ value = operation
112
+ if isinstance(value, (Completed, Cancelled, UncertainHanging)):
113
+ return value
114
+ from ..contracts import Skipped, UncertainSettled
115
+
116
+ if isinstance(value, (Skipped, UncertainSettled)):
117
+ return value
118
+ if isinstance(value, str):
119
+ return Completed(value)
120
+ return Completed(str(value), structured=value)
121
+ except TimeoutError:
122
+ return UncertainHanging(
123
+ f"Tool {call.name} timed out; external effects are unknown",
124
+ resource_locks=spec.resources,
125
+ )
126
+ except asyncio.CancelledError:
127
+ return Cancelled(
128
+ settled=False,
129
+ partial_content="Tool handler was cancelled; external effects are unknown",
130
+ resource_locks=spec.resources,
131
+ )
132
+ except Exception as exc:
133
+ return Completed(f"{type(exc).__name__}: {exc}", is_error=True)
134
+
135
+
136
+ async def _await_with_cancel(awaitable: Awaitable[Any], cancel: asyncio.Event) -> Any:
137
+ operation = asyncio.ensure_future(awaitable)
138
+ cancellation = asyncio.create_task(cancel.wait())
139
+ try:
140
+ done, _ = await asyncio.wait({operation, cancellation}, return_when=asyncio.FIRST_COMPLETED)
141
+ if cancellation in done and not operation.done():
142
+ operation.cancel()
143
+ await asyncio.gather(operation, return_exceptions=True)
144
+ raise asyncio.CancelledError()
145
+ cancellation.cancel()
146
+ return await operation
147
+ finally:
148
+ cancellation.cancel()
149
+ if not operation.done():
150
+ operation.cancel()
151
+ await asyncio.gather(operation, cancellation, return_exceptions=True)
nextloop/loop.py ADDED
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from .contracts import (
6
+ DeliveryOwner,
7
+ EventStore,
8
+ EventType,
9
+ NewEvent,
10
+ RecoveryOutcome,
11
+ TurnContext,
12
+ TurnOutcome,
13
+ )
14
+ from .driver import SessionActor, SessionExecution, TurnDriver
15
+ from .events.repair import pending_final_messages, repair_events
16
+
17
+
18
+ class AgentLoop:
19
+ def __init__(self) -> None:
20
+ self._actors: dict[str, SessionActor] = {}
21
+ self._active: dict[str, SessionExecution] = {}
22
+ self._resource_locks: dict[str, set[str]] = {}
23
+
24
+ async def run(self, context: TurnContext) -> TurnOutcome:
25
+ actor = self._actors.setdefault(context.session_id, SessionActor())
26
+ async with actor.run_lock:
27
+ writer_epoch, last_seq, last_turn = await context.store.attach_writer(
28
+ context.session_id
29
+ )
30
+ execution = SessionExecution(
31
+ context=context,
32
+ actor=actor,
33
+ writer_epoch=writer_epoch,
34
+ seq=last_seq,
35
+ turn=last_turn + 1,
36
+ resource_locks=set(self._resource_locks.get(context.session_id, set())),
37
+ )
38
+ self._active[context.session_id] = execution
39
+ try:
40
+ await execution.append(
41
+ [
42
+ NewEvent(
43
+ event_type=EventType.INPUT_ACCEPTED,
44
+ turn=execution.turn,
45
+ step=0,
46
+ payload={
47
+ "input_id": context.input_id,
48
+ "content": context.initial_input,
49
+ },
50
+ ),
51
+ NewEvent(event_type=EventType.TURN_START, turn=execution.turn, step=0),
52
+ ]
53
+ )
54
+ outcome = await TurnDriver(execution).run()
55
+ self._resource_locks[context.session_id] = set(execution.resource_locks)
56
+ return outcome
57
+ finally:
58
+ self._active.pop(context.session_id, None)
59
+
60
+ async def steer(self, session_id: str, content: str, *, input_id: str | None = None) -> bool:
61
+ execution = self._active.get(session_id)
62
+ if execution is None or not content.strip():
63
+ return False
64
+ async with execution.actor.commit_lock:
65
+ if execution.final_committed:
66
+ return False
67
+ accepted_id = (
68
+ input_id or f"in_{len(execution.actor.inbox) + 1}_{execution.turn}_{execution.step}"
69
+ )
70
+ await execution._append_locked(
71
+ [
72
+ NewEvent(
73
+ event_type=EventType.INPUT_ACCEPTED,
74
+ turn=execution.turn,
75
+ step=execution.step,
76
+ payload={"input_id": accepted_id, "content": content},
77
+ )
78
+ ]
79
+ )
80
+ execution.actor.inbox.append((accepted_id, content))
81
+ return True
82
+
83
+ async def cancel(self, session_id: str, cause: str = "user_stop") -> bool:
84
+ execution = self._active.get(session_id)
85
+ if execution is None:
86
+ return False
87
+ async with execution.actor.commit_lock:
88
+ if execution.final_committed:
89
+ return False
90
+ await execution._append_locked(
91
+ [
92
+ NewEvent(
93
+ event_type=EventType.CANCELLATION_REQUESTED,
94
+ turn=execution.turn,
95
+ step=execution.step,
96
+ payload={"cause": cause},
97
+ )
98
+ ]
99
+ )
100
+ execution.cancel.set()
101
+ return True
102
+
103
+ async def recover(
104
+ self,
105
+ session_id: str,
106
+ store: EventStore,
107
+ delivery: DeliveryOwner,
108
+ ) -> RecoveryOutcome:
109
+ actor = self._actors.setdefault(session_id, SessionActor())
110
+ async with actor.run_lock:
111
+ if session_id in self._active:
112
+ raise RuntimeError("Cannot recover a Session with an active Turn")
113
+ writer_epoch, last_seq, _ = await store.attach_writer(session_id)
114
+ events = list(await store.load_recovery(session_id))
115
+ repaired = repair_events(events)
116
+ seq = last_seq
117
+
118
+ async def append(new_events: Sequence[NewEvent]) -> None:
119
+ nonlocal seq
120
+ if not new_events:
121
+ return
122
+ committed = await store.append(session_id, writer_epoch, seq + 1, new_events)
123
+ seq = committed[-1].seq
124
+
125
+ await append(repaired.events)
126
+ self._resource_locks[session_id] = set(repaired.active_resource_locks)
127
+ delivered = []
128
+ delivery_unknown = []
129
+ for message in pending_final_messages(events):
130
+ try:
131
+ await delivery.deliver(message)
132
+ await append(
133
+ [
134
+ NewEvent(
135
+ event_type=EventType.DELIVERY_COMMITTED,
136
+ turn=message.turn,
137
+ step=message.step,
138
+ payload={
139
+ "final_message_id": message.final_message_id,
140
+ "recovered": True,
141
+ },
142
+ )
143
+ ]
144
+ )
145
+ delivered.append(message.final_message_id)
146
+ except Exception as exc:
147
+ await append(
148
+ [
149
+ NewEvent(
150
+ event_type=EventType.DELIVERY_UNKNOWN,
151
+ turn=message.turn,
152
+ step=message.step,
153
+ payload={
154
+ "final_message_id": message.final_message_id,
155
+ "error": str(exc),
156
+ "recovered": True,
157
+ },
158
+ )
159
+ ]
160
+ )
161
+ delivery_unknown.append(message.final_message_id)
162
+ return RecoveryOutcome(
163
+ session_id=session_id,
164
+ writer_epoch=writer_epoch,
165
+ interrupted_turns=repaired.interrupted_turns,
166
+ delivered_final_ids=tuple(delivered),
167
+ delivery_unknown_final_ids=tuple(delivery_unknown),
168
+ active_resource_locks=repaired.active_resource_locks,
169
+ )
nextloop/py.typed ADDED
File without changes