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.
- nextloop/__init__.py +96 -0
- nextloop/adapters/__init__.py +6 -0
- nextloop/adapters/_sse.py +25 -0
- nextloop/adapters/anthropic.py +211 -0
- nextloop/adapters/memory.py +97 -0
- nextloop/adapters/openai.py +188 -0
- nextloop/adapters/sqlite.py +250 -0
- nextloop/contracts.py +350 -0
- nextloop/driver.py +446 -0
- nextloop/events/__init__.py +17 -0
- nextloop/events/outcome.py +183 -0
- nextloop/events/project.py +186 -0
- nextloop/events/repair.py +143 -0
- nextloop/events/response.py +215 -0
- nextloop/execute/__init__.py +15 -0
- nextloop/execute/batch.py +165 -0
- nextloop/execute/model.py +134 -0
- nextloop/execute/tools.py +151 -0
- nextloop/loop.py +169 -0
- nextloop/py.typed +0 -0
- nextloop-0.1.0rc1.dist-info/METADATA +148 -0
- nextloop-0.1.0rc1.dist-info/RECORD +25 -0
- nextloop-0.1.0rc1.dist-info/WHEEL +5 -0
- nextloop-0.1.0rc1.dist-info/licenses/LICENSE +21 -0
- nextloop-0.1.0rc1.dist-info/top_level.txt +1 -0
|
@@ -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
|