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,186 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from ..contracts import (
8
+ CandidateRequest,
9
+ CapacityError,
10
+ Event,
11
+ EventType,
12
+ LoopConfig,
13
+ Message,
14
+ ModelUsage,
15
+ NewEvent,
16
+ SurfaceOp,
17
+ ToolCall,
18
+ ToolSpec,
19
+ )
20
+
21
+
22
+ def message_to_data(message: Message) -> dict[str, Any]:
23
+ return {
24
+ "role": message.role,
25
+ "content": message.content,
26
+ "tool_calls": [
27
+ {"call_id": call.call_id, "name": call.name, "arguments": call.arguments}
28
+ for call in message.tool_calls
29
+ ],
30
+ "tool_call_id": message.tool_call_id,
31
+ "name": message.name,
32
+ "provider_data": dict(message.provider_data),
33
+ }
34
+
35
+
36
+ def message_from_data(data: Mapping[str, Any]) -> Message:
37
+ return Message(
38
+ role=str(data["role"]),
39
+ content=str(data.get("content", "")),
40
+ tool_calls=tuple(
41
+ ToolCall(
42
+ call_id=str(call["call_id"]),
43
+ name=str(call["name"]),
44
+ arguments=str(call["arguments"]),
45
+ )
46
+ for call in data.get("tool_calls", ())
47
+ ),
48
+ tool_call_id=data.get("tool_call_id"),
49
+ name=data.get("name"),
50
+ provider_data=data.get("provider_data", {}),
51
+ )
52
+
53
+
54
+ def usage_to_data(usage: ModelUsage | None) -> dict[str, Any] | None:
55
+ if usage is None:
56
+ return None
57
+ return {
58
+ "input_tokens": usage.input_tokens,
59
+ "output_tokens": usage.output_tokens,
60
+ "cached_input_tokens": usage.cached_input_tokens,
61
+ }
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Projection:
66
+ request: CandidateRequest
67
+ compaction_reason: str | None
68
+ working_context: Message | None
69
+
70
+
71
+ def latest_working_context(events: Sequence[Event]) -> Message | None:
72
+ active: Message | None = None
73
+ for event in events:
74
+ if event.event_type != EventType.USER_MESSAGE:
75
+ continue
76
+ marker = event.payload.get("working_context")
77
+ if not isinstance(marker, Mapping):
78
+ continue
79
+ action = marker.get("action")
80
+ if action == "set":
81
+ active = message_from_data(event.payload["message"])
82
+ elif action in ("clear", "expired"):
83
+ active = None
84
+ return active
85
+
86
+
87
+ def project_events(
88
+ events: Sequence[Event],
89
+ *,
90
+ session_id: str,
91
+ turn: int,
92
+ step: int,
93
+ config: LoopConfig,
94
+ tools: Sequence[ToolSpec],
95
+ ) -> Projection:
96
+ messages = [Message(role="system", content=config.system_prompt)]
97
+ messages.extend(config.bootstrap_messages)
98
+ for event in events:
99
+ if event.event_type == EventType.COMPACTION:
100
+ messages.append(
101
+ Message(role="user", content="[Task summary]\n" + str(event.payload["summary"]))
102
+ )
103
+ elif event.event_type in (
104
+ EventType.USER_MESSAGE,
105
+ EventType.ASSISTANT_MESSAGE,
106
+ EventType.TOOL_RESULT,
107
+ ):
108
+ messages.append(message_from_data(event.payload["message"]))
109
+ request = CandidateRequest(
110
+ session_id=session_id,
111
+ turn=turn,
112
+ step=step,
113
+ messages=tuple(messages),
114
+ tools=tuple(tools),
115
+ )
116
+ estimated_input = max(1, (len(request.canonical_bytes()) + 3) // 4)
117
+ reason = None
118
+ if estimated_input >= config.context_window * 0.75:
119
+ reason = "input_threshold_75_percent"
120
+ elif estimated_input + config.output_reserve > config.context_window:
121
+ reason = "input_output_capacity"
122
+ return Projection(
123
+ request=request,
124
+ compaction_reason=reason,
125
+ working_context=latest_working_context(events),
126
+ )
127
+
128
+
129
+ def make_compaction_request(projection: Projection, config: LoopConfig) -> CandidateRequest:
130
+ if not config.compaction_prompt.strip():
131
+ raise CapacityError("Compaction is required but compaction_prompt is empty")
132
+ request = projection.request
133
+ return CandidateRequest(
134
+ session_id=request.session_id,
135
+ turn=request.turn,
136
+ step=request.step,
137
+ messages=(
138
+ Message(role="system", content=config.compaction_prompt),
139
+ *config.bootstrap_messages,
140
+ *request.messages[1 + len(config.bootstrap_messages) :],
141
+ ),
142
+ tools=(),
143
+ )
144
+
145
+
146
+ def valid_compaction(summary: str, projection: Projection) -> bool:
147
+ if not summary.strip():
148
+ return False
149
+ previous_size = sum(len(message.content) for message in projection.request.messages)
150
+ return len(summary.strip()) < previous_size
151
+
152
+
153
+ def compaction_events(
154
+ summary: str,
155
+ surface_events: Sequence[Event],
156
+ *,
157
+ turn: int,
158
+ step: int,
159
+ ) -> tuple[NewEvent, ...]:
160
+ events = [
161
+ NewEvent(
162
+ event_type=EventType.COMPACTION,
163
+ turn=turn,
164
+ step=step,
165
+ surface_op=SurfaceOp.EPOCH,
166
+ payload={"summary": summary.strip()},
167
+ )
168
+ ]
169
+ working_context = latest_working_context(surface_events)
170
+ if working_context is not None:
171
+ marker = working_context.provider_data.get(
172
+ "working_context", {"action": "set", "scope": "session"}
173
+ )
174
+ events.append(
175
+ NewEvent(
176
+ event_type=EventType.USER_MESSAGE,
177
+ turn=turn,
178
+ step=step,
179
+ surface_op=SurfaceOp.APPEND,
180
+ payload={
181
+ "message": message_to_data(working_context),
182
+ "working_context": marker,
183
+ },
184
+ )
185
+ )
186
+ return tuple(events)
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from ..contracts import (
8
+ Event,
9
+ EventType,
10
+ FinalMessage,
11
+ NewEvent,
12
+ Skipped,
13
+ SurfaceOp,
14
+ ToolCall,
15
+ UncertainHanging,
16
+ )
17
+ from .outcome import outcome_event
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RepairDecision:
22
+ events: tuple[NewEvent, ...]
23
+ interrupted_turns: tuple[int, ...]
24
+ active_resource_locks: frozenset[str]
25
+
26
+
27
+ def active_resource_locks(events: Sequence[Event]) -> frozenset[str]:
28
+ locks: set[str] = set()
29
+ for event in events:
30
+ if event.event_type != EventType.TOOL_RESULT:
31
+ continue
32
+ outcome = event.payload.get("outcome", {})
33
+ if not isinstance(outcome, dict):
34
+ continue
35
+ kind = outcome.get("kind")
36
+ if kind == "uncertain_hanging" or kind == "cancelled" and not outcome.get("settled", False):
37
+ locks.update(outcome.get("resource_locks", ()))
38
+ elif kind == "completed":
39
+ locks.difference_update(outcome.get("release_resources", ()))
40
+ return frozenset(locks)
41
+
42
+
43
+ def repair_events(events: Sequence[Event]) -> RepairDecision:
44
+ starts = {event.turn: event for event in events if event.event_type == EventType.TURN_START}
45
+ closed = {event.turn for event in events if event.event_type == EventType.TURN_END}
46
+ new_events: list[NewEvent] = []
47
+ interrupted: list[int] = []
48
+ for turn in sorted(set(starts) - closed):
49
+ turn_events = [event for event in events if event.turn == turn]
50
+ interrupted.append(turn)
51
+ started_steps = {
52
+ event.step for event in turn_events if event.event_type == EventType.STEP_START
53
+ }
54
+ ended_steps = {
55
+ event.step for event in turn_events if event.event_type == EventType.STEP_END
56
+ }
57
+ calls_started = {
58
+ str(event.payload["call_id"]): event
59
+ for event in turn_events
60
+ if event.event_type == EventType.TOOL_CALL
61
+ }
62
+ results = {
63
+ str(event.payload["call_id"])
64
+ for event in turn_events
65
+ if event.event_type == EventType.TOOL_RESULT
66
+ }
67
+ for assistant in turn_events:
68
+ if (
69
+ assistant.event_type != EventType.ASSISTANT_MESSAGE
70
+ or assistant.surface_op != SurfaceOp.APPEND
71
+ ):
72
+ continue
73
+ for raw_call in assistant.payload["message"].get("tool_calls", ()):
74
+ call = ToolCall(
75
+ call_id=str(raw_call["call_id"]),
76
+ name=str(raw_call["name"]),
77
+ arguments=str(raw_call["arguments"]),
78
+ )
79
+ if call.call_id in results:
80
+ continue
81
+ started = calls_started.get(call.call_id)
82
+ if started is None:
83
+ outcome: Any = Skipped("Interrupted before Runtime dispatch")
84
+ else:
85
+ outcome = UncertainHanging(
86
+ "Process recovery found a Tool Call without a committed result; "
87
+ "effects are unknown",
88
+ resource_locks=tuple(started.payload.get("resources", ())),
89
+ )
90
+ new_events.append(outcome_event(call, outcome, turn=turn, step=assistant.step))
91
+ for step in sorted(started_steps - ended_steps):
92
+ new_events.append(
93
+ NewEvent(
94
+ event_type=EventType.STEP_END,
95
+ turn=turn,
96
+ step=step,
97
+ payload={"status": "interrupted"},
98
+ )
99
+ )
100
+ new_events.append(
101
+ NewEvent(
102
+ event_type=EventType.TURN_END,
103
+ turn=turn,
104
+ step=max(started_steps, default=0),
105
+ payload={"status": "interrupted"},
106
+ )
107
+ )
108
+ existing_locks = set(active_resource_locks(events))
109
+ for event in new_events:
110
+ if event.event_type == EventType.TOOL_RESULT:
111
+ outcome = event.payload["outcome"]
112
+ if outcome["kind"] == "uncertain_hanging":
113
+ existing_locks.update(outcome.get("resource_locks", ()))
114
+ return RepairDecision(
115
+ events=tuple(new_events),
116
+ interrupted_turns=tuple(interrupted),
117
+ active_resource_locks=frozenset(existing_locks),
118
+ )
119
+
120
+
121
+ def pending_final_messages(events: Sequence[Event]) -> tuple[FinalMessage, ...]:
122
+ delivered = {
123
+ str(event.payload["final_message_id"])
124
+ for event in events
125
+ if event.event_type == EventType.DELIVERY_COMMITTED
126
+ }
127
+ pending = []
128
+ for event in events:
129
+ if event.event_type != EventType.FINAL_MESSAGE:
130
+ continue
131
+ final_message_id = str(event.payload["final_message_id"])
132
+ if final_message_id in delivered:
133
+ continue
134
+ pending.append(
135
+ FinalMessage(
136
+ final_message_id=final_message_id,
137
+ session_id=event.session_id,
138
+ turn=event.turn,
139
+ step=event.step,
140
+ content=str(event.payload["content"]),
141
+ )
142
+ )
143
+ return tuple(pending)
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from dataclasses import dataclass
5
+ from enum import StrEnum
6
+ from typing import Any
7
+
8
+ from ..contracts import (
9
+ EventType,
10
+ FinishReason,
11
+ Message,
12
+ ModelResponse,
13
+ NewEvent,
14
+ SurfaceOp,
15
+ ToolCall,
16
+ )
17
+ from .project import message_to_data, usage_to_data
18
+
19
+
20
+ class ResponseAction(StrEnum):
21
+ NEXT_STEP = "next_step"
22
+ RUN_TOOLS = "run_tools"
23
+ FINAL_CANDIDATE = "final_candidate"
24
+ FAILED = "failed"
25
+ CANCELLED = "cancelled"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ResponseDecision:
30
+ action: ResponseAction
31
+ events: tuple[NewEvent, ...]
32
+ calls: tuple[ToolCall, ...] = ()
33
+ final_content: str = ""
34
+ error: str | None = None
35
+
36
+
37
+ def _assistant_event(
38
+ response: ModelResponse, *, turn: int, step: int, attempt: int, visible: bool
39
+ ) -> NewEvent:
40
+ content = response.content or response.refusal or ""
41
+ message = Message(
42
+ role="assistant",
43
+ content=content,
44
+ tool_calls=response.tool_calls,
45
+ provider_data=response.provider_data,
46
+ )
47
+ return NewEvent(
48
+ event_type=EventType.ASSISTANT_MESSAGE,
49
+ turn=turn,
50
+ step=step,
51
+ attempt=attempt,
52
+ surface_op=SurfaceOp.APPEND if visible else SurfaceOp.NONE,
53
+ payload={
54
+ "message": message_to_data(message),
55
+ "finish_reason": response.finish_reason.value,
56
+ "usage": usage_to_data(response.usage),
57
+ },
58
+ )
59
+
60
+
61
+ def _correction(feedback: str, *, turn: int, step: int) -> NewEvent:
62
+ return NewEvent(
63
+ event_type=EventType.USER_MESSAGE,
64
+ turn=turn,
65
+ step=step,
66
+ surface_op=SurfaceOp.APPEND,
67
+ payload={
68
+ "message": message_to_data(
69
+ Message(role="user", content="[Protocol correction]\n" + feedback)
70
+ ),
71
+ "protocol_correction": True,
72
+ },
73
+ )
74
+
75
+
76
+ def _correct_or_fail(
77
+ response: ModelResponse,
78
+ feedback: str,
79
+ *,
80
+ turn: int,
81
+ step: int,
82
+ attempt: int,
83
+ corrections: int,
84
+ max_corrections: int,
85
+ ) -> ResponseDecision:
86
+ assistant = _assistant_event(response, turn=turn, step=step, attempt=attempt, visible=False)
87
+ if corrections >= max_corrections:
88
+ return ResponseDecision(
89
+ action=ResponseAction.FAILED,
90
+ events=(assistant,),
91
+ error="ProtocolExhausted: protocol correction limit exhausted",
92
+ )
93
+ return ResponseDecision(
94
+ action=ResponseAction.NEXT_STEP,
95
+ events=(assistant, _correction(feedback, turn=turn, step=step)),
96
+ )
97
+
98
+
99
+ def decide_response(
100
+ response: ModelResponse,
101
+ *,
102
+ turn: int,
103
+ step: int,
104
+ attempt: int,
105
+ corrections: int,
106
+ max_corrections: int,
107
+ ) -> ResponseDecision:
108
+ if response.finish_reason == FinishReason.CANCELLED:
109
+ return ResponseDecision(
110
+ action=ResponseAction.CANCELLED,
111
+ events=(
112
+ _assistant_event(response, turn=turn, step=step, attempt=attempt, visible=False),
113
+ ),
114
+ )
115
+ if response.finish_reason in (FinishReason.ERROR, FinishReason.CONTENT_FILTER):
116
+ return ResponseDecision(
117
+ action=ResponseAction.FAILED,
118
+ events=(
119
+ _assistant_event(response, turn=turn, step=step, attempt=attempt, visible=False),
120
+ ),
121
+ error=f"Provider terminal response: {response.finish_reason.value}",
122
+ )
123
+ if response.finish_reason == FinishReason.LENGTH:
124
+ return _correct_or_fail(
125
+ response,
126
+ "The previous response was truncated. No Tool Call was executed. "
127
+ "Return a complete shorter response.",
128
+ turn=turn,
129
+ step=step,
130
+ attempt=attempt,
131
+ corrections=corrections,
132
+ max_corrections=max_corrections,
133
+ )
134
+ if response.finish_reason == FinishReason.STOP and response.tool_calls:
135
+ return _correct_or_fail(
136
+ response,
137
+ "The stop reason is inconsistent with Tool Calls. "
138
+ "Return one complete protocol-valid response.",
139
+ turn=turn,
140
+ step=step,
141
+ attempt=attempt,
142
+ corrections=corrections,
143
+ max_corrections=max_corrections,
144
+ )
145
+ if response.finish_reason == FinishReason.TOOL_CALLS and not response.tool_calls:
146
+ return _correct_or_fail(
147
+ response,
148
+ "The Tool-call finish reason supplied no Tool Calls. Return a valid response.",
149
+ turn=turn,
150
+ step=step,
151
+ attempt=attempt,
152
+ corrections=corrections,
153
+ max_corrections=max_corrections,
154
+ )
155
+ if not response.tool_calls:
156
+ final_content = response.content or response.refusal or ""
157
+ if not final_content:
158
+ return _correct_or_fail(
159
+ response,
160
+ "The previous response was empty. "
161
+ "Return a nonempty answer or a valid Tool Call batch.",
162
+ turn=turn,
163
+ step=step,
164
+ attempt=attempt,
165
+ corrections=corrections,
166
+ max_corrections=max_corrections,
167
+ )
168
+ return ResponseDecision(
169
+ action=ResponseAction.FINAL_CANDIDATE,
170
+ events=(
171
+ _assistant_event(response, turn=turn, step=step, attempt=attempt, visible=True),
172
+ ),
173
+ final_content=final_content,
174
+ )
175
+ return ResponseDecision(
176
+ action=ResponseAction.RUN_TOOLS,
177
+ events=(_assistant_event(response, turn=turn, step=step, attempt=attempt, visible=True),),
178
+ calls=response.tool_calls,
179
+ )
180
+
181
+
182
+ def reject_batch(
183
+ issues: Sequence[Mapping[str, Any]],
184
+ *,
185
+ turn: int,
186
+ step: int,
187
+ corrections: int,
188
+ max_corrections: int,
189
+ ) -> ResponseDecision:
190
+ details = "; ".join(str(issue["message"]) for issue in issues)
191
+ rejection = NewEvent(
192
+ event_type=EventType.TOOL_BATCH_REJECTED,
193
+ turn=turn,
194
+ step=step,
195
+ surface_op=SurfaceOp.NONE,
196
+ payload={"issues": list(issues), "executed": False},
197
+ )
198
+ if corrections >= max_corrections:
199
+ return ResponseDecision(
200
+ action=ResponseAction.FAILED,
201
+ events=(rejection,),
202
+ error="ProtocolExhausted: protocol correction limit exhausted",
203
+ )
204
+ return ResponseDecision(
205
+ action=ResponseAction.NEXT_STEP,
206
+ events=(
207
+ rejection,
208
+ _correction(
209
+ "The complete Tool batch was rejected and no Tool executed. "
210
+ "Correct every error without guessing IDs or arguments: " + details,
211
+ turn=turn,
212
+ step=step,
213
+ ),
214
+ ),
215
+ )
@@ -0,0 +1,15 @@
1
+ from ..contracts import ValidatedCall, ValidationIssue
2
+ from .batch import BatchExecutor, BatchResult
3
+ from .model import ModelCaller, ModelCallResult
4
+ from .tools import ToolContext, ToolRegistry
5
+
6
+ __all__ = [
7
+ "BatchExecutor",
8
+ "BatchResult",
9
+ "ModelCaller",
10
+ "ModelCallResult",
11
+ "ToolContext",
12
+ "ToolRegistry",
13
+ "ValidatedCall",
14
+ "ValidationIssue",
15
+ ]