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 ADDED
@@ -0,0 +1,96 @@
1
+ from .adapters.anthropic import AnthropicProvider
2
+ from .adapters.memory import InMemoryEventStore
3
+ from .adapters.openai import OpenAIProvider
4
+ from .adapters.sqlite import SQLiteEventStore
5
+ from .contracts import (
6
+ API_VERSION,
7
+ EVENT_SCHEMA_VERSION,
8
+ Cancelled,
9
+ CandidateRequest,
10
+ CapacityError,
11
+ Completed,
12
+ DeliveryOwner,
13
+ Event,
14
+ EventStore,
15
+ EventType,
16
+ FinalMessage,
17
+ FinishReason,
18
+ InvariantViolation,
19
+ LoopConfig,
20
+ Message,
21
+ ModelProvider,
22
+ ModelResponse,
23
+ ModelUsage,
24
+ NewEvent,
25
+ NextloopError,
26
+ ProtocolExhausted,
27
+ ProviderError,
28
+ RecoveryOutcome,
29
+ Skipped,
30
+ SurfaceOp,
31
+ ToolCall,
32
+ ToolExecutionMode,
33
+ ToolOutcome,
34
+ ToolRuntime,
35
+ ToolSpec,
36
+ TurnContext,
37
+ TurnOutcome,
38
+ TurnStatus,
39
+ UncertainHanging,
40
+ UncertainSettled,
41
+ ValidatedCall,
42
+ ValidationIssue,
43
+ new_id,
44
+ utc_now,
45
+ )
46
+ from .execute.tools import ToolContext, ToolRegistry
47
+ from .loop import AgentLoop
48
+
49
+ __all__ = [
50
+ "API_VERSION",
51
+ "EVENT_SCHEMA_VERSION",
52
+ "AgentLoop",
53
+ "AnthropicProvider",
54
+ "CandidateRequest",
55
+ "Cancelled",
56
+ "CapacityError",
57
+ "Completed",
58
+ "DeliveryOwner",
59
+ "Event",
60
+ "EventStore",
61
+ "EventType",
62
+ "FinalMessage",
63
+ "FinishReason",
64
+ "InMemoryEventStore",
65
+ "InvariantViolation",
66
+ "LoopConfig",
67
+ "Message",
68
+ "ModelProvider",
69
+ "ModelResponse",
70
+ "ModelUsage",
71
+ "NewEvent",
72
+ "NextloopError",
73
+ "OpenAIProvider",
74
+ "ProtocolExhausted",
75
+ "ProviderError",
76
+ "RecoveryOutcome",
77
+ "SQLiteEventStore",
78
+ "Skipped",
79
+ "SurfaceOp",
80
+ "ToolCall",
81
+ "ToolContext",
82
+ "ToolExecutionMode",
83
+ "ToolOutcome",
84
+ "ToolRegistry",
85
+ "ToolRuntime",
86
+ "ToolSpec",
87
+ "TurnContext",
88
+ "TurnOutcome",
89
+ "TurnStatus",
90
+ "UncertainHanging",
91
+ "UncertainSettled",
92
+ "ValidatedCall",
93
+ "ValidationIssue",
94
+ "new_id",
95
+ "utc_now",
96
+ ]
@@ -0,0 +1,6 @@
1
+ from .anthropic import AnthropicProvider
2
+ from .memory import InMemoryEventStore
3
+ from .openai import OpenAIProvider
4
+ from .sqlite import SQLiteEventStore
5
+
6
+ __all__ = ["AnthropicProvider", "InMemoryEventStore", "OpenAIProvider", "SQLiteEventStore"]
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator
4
+
5
+ import httpx
6
+
7
+
8
+ async def data_events(response: httpx.Response) -> AsyncIterator[tuple[str, str]]:
9
+ event = "message"
10
+ data: list[str] = []
11
+ async for line in response.aiter_lines():
12
+ if not line:
13
+ if data:
14
+ yield event, "\n".join(data)
15
+ event = "message"
16
+ data = []
17
+ continue
18
+ if line.startswith(":"):
19
+ continue
20
+ if line.startswith("event:"):
21
+ event = line[6:].strip()
22
+ elif line.startswith("data:"):
23
+ data.append(line[5:].lstrip())
24
+ if data:
25
+ yield event, "\n".join(data)
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from ..contracts import (
11
+ CandidateRequest,
12
+ FinishReason,
13
+ Message,
14
+ ModelResponse,
15
+ ModelUsage,
16
+ ProviderError,
17
+ ToolCall,
18
+ )
19
+ from ._sse import data_events
20
+
21
+
22
+ class AnthropicProvider:
23
+ def __init__(
24
+ self,
25
+ *,
26
+ model_id: str,
27
+ base_url: str,
28
+ api_key: str,
29
+ default_parameters: Mapping[str, Any] | None = None,
30
+ timeout_seconds: float = 60.0,
31
+ http_client: httpx.AsyncClient | None = None,
32
+ ) -> None:
33
+ self.model_id = model_id
34
+ self.base_url = base_url.rstrip("/")
35
+ self.api_key = api_key
36
+ self.default_parameters = {"max_tokens": 4096, **dict(default_parameters or {})}
37
+ self.timeout_seconds = timeout_seconds
38
+ self.http_client = http_client
39
+
40
+ async def complete(
41
+ self,
42
+ request: CandidateRequest,
43
+ *,
44
+ attempt: int,
45
+ cancel: asyncio.Event,
46
+ ) -> ModelResponse:
47
+ system = "\n\n".join(
48
+ message.content for message in request.messages if message.role == "system"
49
+ )
50
+ body = {
51
+ **self.default_parameters,
52
+ "model": self.model_id,
53
+ "messages": self._messages(request.messages),
54
+ "stream": True,
55
+ }
56
+ if system:
57
+ body["system"] = system
58
+ if request.tools:
59
+ body["tools"] = [
60
+ {
61
+ "name": tool.name,
62
+ "description": tool.description,
63
+ "input_schema": dict(tool.parameters),
64
+ }
65
+ for tool in request.tools
66
+ ]
67
+ headers = {
68
+ "x-api-key": self.api_key,
69
+ "anthropic-version": "2023-06-01",
70
+ "Content-Type": "application/json",
71
+ }
72
+ client = self.http_client or httpx.AsyncClient(timeout=self.timeout_seconds)
73
+ text_blocks: dict[int, list[str]] = {}
74
+ tool_blocks: dict[int, dict[str, Any]] = {}
75
+ finish_reason = FinishReason.STOP
76
+ input_tokens = None
77
+ output_tokens = None
78
+ try:
79
+ async with client.stream(
80
+ "POST", self._endpoint(), json=body, headers=headers
81
+ ) as response:
82
+ if response.status_code >= 400:
83
+ error_body = await response.aread()
84
+ error_text = error_body.decode("utf-8", errors="replace")
85
+ raise ProviderError(
86
+ f"Anthropic HTTP {response.status_code}: {error_text}",
87
+ retryable=response.status_code in (408, 409, 429)
88
+ or response.status_code >= 500,
89
+ code=str(response.status_code),
90
+ )
91
+ async for event, data in data_events(response):
92
+ if cancel.is_set():
93
+ return ModelResponse(
94
+ content=self._text(text_blocks),
95
+ finish_reason=FinishReason.CANCELLED,
96
+ )
97
+ payload = json.loads(data)
98
+ if event == "message_start":
99
+ usage = payload.get("message", {}).get("usage", {})
100
+ input_tokens = usage.get("input_tokens", input_tokens)
101
+ elif event == "content_block_start":
102
+ index = int(payload["index"])
103
+ block = payload["content_block"]
104
+ if block["type"] == "text":
105
+ text_blocks[index] = [block.get("text", "")]
106
+ elif block["type"] == "tool_use":
107
+ tool_blocks[index] = {
108
+ "id": block["id"],
109
+ "name": block["name"],
110
+ "arguments": (
111
+ json.dumps(block["input"], separators=(",", ":"))
112
+ if block.get("input")
113
+ else ""
114
+ ),
115
+ }
116
+ elif event == "content_block_delta":
117
+ index = int(payload["index"])
118
+ delta = payload["delta"]
119
+ if delta["type"] == "text_delta":
120
+ text_blocks.setdefault(index, []).append(delta["text"])
121
+ elif delta["type"] == "input_json_delta":
122
+ tool_blocks[index]["arguments"] += delta["partial_json"]
123
+ elif event == "message_delta":
124
+ stop_reason = payload.get("delta", {}).get("stop_reason")
125
+ finish_reason = self._finish_reason(stop_reason) or finish_reason
126
+ output_tokens = payload.get("usage", {}).get("output_tokens", output_tokens)
127
+ elif event == "error":
128
+ error = payload.get("error", {})
129
+ raise ProviderError(
130
+ f"Anthropic stream error: {error}",
131
+ retryable=error.get("type") in ("overloaded_error", "rate_limit_error"),
132
+ partial_output=bool(text_blocks or tool_blocks),
133
+ code=error.get("type"),
134
+ )
135
+ return ModelResponse(
136
+ content=self._text(text_blocks),
137
+ tool_calls=tuple(
138
+ ToolCall(block["id"], block["name"], block["arguments"])
139
+ for _, block in sorted(tool_blocks.items())
140
+ ),
141
+ finish_reason=finish_reason,
142
+ usage=ModelUsage(input_tokens=input_tokens, output_tokens=output_tokens),
143
+ )
144
+ except ProviderError:
145
+ raise
146
+ except (TimeoutError, httpx.HTTPError, json.JSONDecodeError, KeyError) as exc:
147
+ raise ProviderError(
148
+ f"Anthropic stream failed: {exc}",
149
+ retryable=True,
150
+ partial_output=bool(text_blocks or tool_blocks),
151
+ ) from exc
152
+ finally:
153
+ if self.http_client is None:
154
+ await client.aclose()
155
+
156
+ def _endpoint(self) -> str:
157
+ if self.base_url.endswith("/v1/messages"):
158
+ return self.base_url
159
+ return self.base_url + "/v1/messages"
160
+
161
+ @staticmethod
162
+ def _text(blocks: Mapping[int, list[str]]) -> str:
163
+ return "".join("".join(parts) for _, parts in sorted(blocks.items()))
164
+
165
+ @staticmethod
166
+ def _messages(messages: tuple[Message, ...]) -> list[dict[str, Any]]:
167
+ result = []
168
+ for message in messages:
169
+ if message.role == "system":
170
+ continue
171
+ if message.role == "assistant":
172
+ content: list[dict[str, Any]] = []
173
+ if message.content:
174
+ content.append({"type": "text", "text": message.content})
175
+ content.extend(
176
+ {
177
+ "type": "tool_use",
178
+ "id": call.call_id,
179
+ "name": call.name,
180
+ "input": json.loads(call.arguments),
181
+ }
182
+ for call in message.tool_calls
183
+ )
184
+ result.append({"role": "assistant", "content": content})
185
+ elif message.role == "tool":
186
+ result.append(
187
+ {
188
+ "role": "user",
189
+ "content": [
190
+ {
191
+ "type": "tool_result",
192
+ "tool_use_id": message.tool_call_id,
193
+ "content": message.content,
194
+ "is_error": message.content.startswith("[tool error]"),
195
+ }
196
+ ],
197
+ }
198
+ )
199
+ else:
200
+ result.append({"role": "user", "content": message.content})
201
+ return result
202
+
203
+ @staticmethod
204
+ def _finish_reason(value: Any) -> FinishReason | None:
205
+ return {
206
+ "end_turn": FinishReason.STOP,
207
+ "stop_sequence": FinishReason.STOP,
208
+ "tool_use": FinishReason.TOOL_CALLS,
209
+ "max_tokens": FinishReason.LENGTH,
210
+ "refusal": FinishReason.CONTENT_FILTER,
211
+ }.get(value)
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ from collections import defaultdict
6
+ from collections.abc import Sequence
7
+
8
+ from ..contracts import (
9
+ Event,
10
+ InvariantViolation,
11
+ NewEvent,
12
+ SurfaceOp,
13
+ utc_now,
14
+ )
15
+
16
+
17
+ class InMemoryEventStore:
18
+ def __init__(self) -> None:
19
+ self._events: dict[str, list[Event]] = defaultdict(list)
20
+ self._epochs: dict[str, int] = defaultdict(int)
21
+ self._lock = asyncio.Lock()
22
+
23
+ async def attach_writer(self, session_id: str) -> tuple[int, int, int]:
24
+ async with self._lock:
25
+ self._epochs[session_id] += 1
26
+ events = self._events[session_id]
27
+ last_turn = max((event.turn for event in events), default=0)
28
+ return self._epochs[session_id], len(events), last_turn
29
+
30
+ async def append(
31
+ self,
32
+ session_id: str,
33
+ writer_epoch: int,
34
+ expected_seq: int,
35
+ events: Sequence[NewEvent],
36
+ ) -> Sequence[Event]:
37
+ async with self._lock:
38
+ if self._epochs[session_id] != writer_epoch:
39
+ raise InvariantViolation("Stale writer_epoch")
40
+ existing = self._events[session_id]
41
+ if expected_seq != len(existing) + 1:
42
+ return self._idempotent(existing, events)
43
+ committed = []
44
+ for offset, draft in enumerate(events):
45
+ event = Event(
46
+ session_id=session_id,
47
+ seq=expected_seq + offset,
48
+ turn=draft.turn,
49
+ step=draft.step,
50
+ attempt=draft.attempt,
51
+ writer_epoch=writer_epoch,
52
+ event_id=draft.event_id,
53
+ event_type=draft.event_type,
54
+ surface_op=draft.surface_op,
55
+ payload=copy.deepcopy(dict(draft.payload)),
56
+ occurred_at=utc_now(),
57
+ )
58
+ existing.append(event)
59
+ committed.append(event)
60
+ return committed
61
+
62
+ async def load_surface(self, session_id: str) -> Sequence[Event]:
63
+ async with self._lock:
64
+ events = self._events[session_id]
65
+ epoch_seq = max(
66
+ (event.seq for event in events if event.surface_op == SurfaceOp.EPOCH),
67
+ default=1,
68
+ )
69
+ return tuple(
70
+ copy.deepcopy(event)
71
+ for event in events
72
+ if event.seq >= epoch_seq and event.surface_op != SurfaceOp.NONE
73
+ )
74
+
75
+ async def load_recovery(self, session_id: str) -> Sequence[Event]:
76
+ async with self._lock:
77
+ return tuple(copy.deepcopy(self._events[session_id]))
78
+
79
+ @staticmethod
80
+ def _idempotent(existing: list[Event], drafts: Sequence[NewEvent]) -> Sequence[Event]:
81
+ by_id = {event.event_id: event for event in existing}
82
+ committed = []
83
+ for draft in drafts:
84
+ event = by_id.get(draft.event_id)
85
+ if event is None:
86
+ raise InvariantViolation("Expected sequence does not match Session tail")
87
+ if (
88
+ event.event_type != draft.event_type
89
+ or event.turn != draft.turn
90
+ or event.step != draft.step
91
+ or event.attempt != draft.attempt
92
+ or event.surface_op != draft.surface_op
93
+ or event.payload != dict(draft.payload)
94
+ ):
95
+ raise InvariantViolation("Event ID reused with different content")
96
+ committed.append(event)
97
+ return committed
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from ..contracts import (
11
+ CandidateRequest,
12
+ FinishReason,
13
+ Message,
14
+ ModelResponse,
15
+ ModelUsage,
16
+ ProviderError,
17
+ ToolCall,
18
+ )
19
+ from ._sse import data_events
20
+
21
+
22
+ class OpenAIProvider:
23
+ def __init__(
24
+ self,
25
+ *,
26
+ model_id: str,
27
+ base_url: str,
28
+ api_key: str,
29
+ default_parameters: Mapping[str, Any] | None = None,
30
+ timeout_seconds: float = 60.0,
31
+ http_client: httpx.AsyncClient | None = None,
32
+ ) -> None:
33
+ self.model_id = model_id
34
+ self.base_url = base_url.rstrip("/")
35
+ self.api_key = api_key
36
+ self.default_parameters = dict(default_parameters or {})
37
+ self.timeout_seconds = timeout_seconds
38
+ self.http_client = http_client
39
+
40
+ async def complete(
41
+ self,
42
+ request: CandidateRequest,
43
+ *,
44
+ attempt: int,
45
+ cancel: asyncio.Event,
46
+ ) -> ModelResponse:
47
+ body = {
48
+ **self.default_parameters,
49
+ "model": self.model_id,
50
+ "messages": [self._message(message) for message in request.messages],
51
+ "stream": True,
52
+ }
53
+ if request.tools:
54
+ body["tools"] = [
55
+ {
56
+ "type": "function",
57
+ "function": {
58
+ "name": tool.name,
59
+ "description": tool.description,
60
+ "parameters": dict(tool.parameters),
61
+ },
62
+ }
63
+ for tool in request.tools
64
+ ]
65
+ headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
66
+ client = self.http_client or httpx.AsyncClient(timeout=self.timeout_seconds)
67
+ content_parts: list[str] = []
68
+ refusal_parts: list[str] = []
69
+ calls: dict[int, dict[str, str]] = {}
70
+ finish_reason = FinishReason.STOP
71
+ usage: ModelUsage | None = None
72
+ try:
73
+ async with client.stream(
74
+ "POST",
75
+ self._endpoint(),
76
+ json=body,
77
+ headers=headers,
78
+ ) as response:
79
+ if response.status_code >= 400:
80
+ error_body = await response.aread()
81
+ error_text = error_body.decode("utf-8", errors="replace")
82
+ raise ProviderError(
83
+ f"OpenAI HTTP {response.status_code}: {error_text}",
84
+ retryable=response.status_code in (408, 409, 429)
85
+ or response.status_code >= 500,
86
+ code=str(response.status_code),
87
+ )
88
+ async for _event, data in data_events(response):
89
+ if cancel.is_set():
90
+ return ModelResponse(
91
+ content="".join(content_parts),
92
+ finish_reason=FinishReason.CANCELLED,
93
+ )
94
+ if data == "[DONE]":
95
+ break
96
+ chunk = json.loads(data)
97
+ usage = self._usage(chunk.get("usage")) or usage
98
+ choices = chunk.get("choices") or []
99
+ if not choices:
100
+ continue
101
+ choice = choices[0]
102
+ delta = choice.get("delta") or {}
103
+ if delta.get("content"):
104
+ content_parts.append(str(delta["content"]))
105
+ if delta.get("refusal"):
106
+ refusal_parts.append(str(delta["refusal"]))
107
+ for fragment in delta.get("tool_calls") or []:
108
+ index = int(fragment["index"])
109
+ current = calls.setdefault(
110
+ index, {"id": f"call_{index}", "name": "", "arguments": ""}
111
+ )
112
+ if fragment.get("id"):
113
+ current["id"] = str(fragment["id"])
114
+ function = fragment.get("function") or {}
115
+ if function.get("name"):
116
+ current["name"] += str(function["name"])
117
+ if function.get("arguments"):
118
+ current["arguments"] += str(function["arguments"])
119
+ finish_reason = (
120
+ self._finish_reason(choice.get("finish_reason")) or finish_reason
121
+ )
122
+ return ModelResponse(
123
+ content="".join(content_parts),
124
+ refusal="".join(refusal_parts) or None,
125
+ tool_calls=tuple(
126
+ ToolCall(call["id"], call["name"], call["arguments"])
127
+ for _, call in sorted(calls.items())
128
+ ),
129
+ finish_reason=finish_reason,
130
+ usage=usage,
131
+ )
132
+ except ProviderError:
133
+ raise
134
+ except (TimeoutError, httpx.HTTPError, json.JSONDecodeError) as exc:
135
+ partial = bool(content_parts or refusal_parts or calls)
136
+ raise ProviderError(
137
+ f"OpenAI stream failed: {exc}",
138
+ retryable=True,
139
+ partial_output=partial,
140
+ ) from exc
141
+ finally:
142
+ if self.http_client is None:
143
+ await client.aclose()
144
+
145
+ def _endpoint(self) -> str:
146
+ if self.base_url.endswith("/chat/completions"):
147
+ return self.base_url
148
+ return self.base_url + "/chat/completions"
149
+
150
+ @staticmethod
151
+ def _message(message: Message) -> dict[str, Any]:
152
+ if message.role == "tool":
153
+ return {
154
+ "role": "tool",
155
+ "tool_call_id": message.tool_call_id,
156
+ "content": message.content,
157
+ }
158
+ value: dict[str, Any] = {"role": message.role, "content": message.content or None}
159
+ if message.tool_calls:
160
+ value["tool_calls"] = [
161
+ {
162
+ "id": call.call_id,
163
+ "type": "function",
164
+ "function": {"name": call.name, "arguments": call.arguments},
165
+ }
166
+ for call in message.tool_calls
167
+ ]
168
+ return value
169
+
170
+ @staticmethod
171
+ def _finish_reason(value: Any) -> FinishReason | None:
172
+ return {
173
+ "stop": FinishReason.STOP,
174
+ "tool_calls": FinishReason.TOOL_CALLS,
175
+ "length": FinishReason.LENGTH,
176
+ "content_filter": FinishReason.CONTENT_FILTER,
177
+ }.get(value)
178
+
179
+ @staticmethod
180
+ def _usage(value: Any) -> ModelUsage | None:
181
+ if not isinstance(value, Mapping):
182
+ return None
183
+ details = value.get("prompt_tokens_details") or {}
184
+ return ModelUsage(
185
+ input_tokens=value.get("prompt_tokens"),
186
+ output_tokens=value.get("completion_tokens"),
187
+ cached_input_tokens=details.get("cached_tokens"),
188
+ )