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,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
|
+
]
|