python-codex 0.0.1__py3-none-any.whl → 0.1.1__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.
- pycodex/__init__.py +141 -2
- pycodex/agent.py +290 -0
- pycodex/cli.py +705 -0
- pycodex/collaboration.py +21 -0
- pycodex/context.py +580 -0
- pycodex/doctor.py +360 -0
- pycodex/model.py +533 -0
- pycodex/portable.py +390 -0
- pycodex/portable_server.py +205 -0
- pycodex/prompts/collaboration_default.md +11 -0
- pycodex/prompts/collaboration_plan.md +128 -0
- pycodex/prompts/default_base_instructions.md +275 -0
- pycodex/prompts/exec_tools.json +411 -0
- pycodex/prompts/models.json +847 -0
- pycodex/prompts/permissions/approval_policy/never.md +1 -0
- pycodex/prompts/permissions/approval_policy/on_failure.md +1 -0
- pycodex/prompts/permissions/approval_policy/on_request.md +57 -0
- pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +33 -0
- pycodex/prompts/permissions/approval_policy/unless_trusted.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/read_only.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/workspace_write.md +1 -0
- pycodex/prompts/subagent_tools.json +163 -0
- pycodex/protocol.py +347 -0
- pycodex/runtime.py +204 -0
- pycodex/runtime_services.py +409 -0
- pycodex/tools/__init__.py +58 -0
- pycodex/tools/agent_tool_schemas.py +70 -0
- pycodex/tools/apply_patch_tool.py +363 -0
- pycodex/tools/base_tool.py +168 -0
- pycodex/tools/close_agent_tool.py +55 -0
- pycodex/tools/code_mode_manager.py +519 -0
- pycodex/tools/exec_command_tool.py +96 -0
- pycodex/tools/exec_runtime.js +161 -0
- pycodex/tools/exec_tool.py +48 -0
- pycodex/tools/grep_files_tool.py +150 -0
- pycodex/tools/list_dir_tool.py +135 -0
- pycodex/tools/read_file_tool.py +217 -0
- pycodex/tools/request_permissions_tool.py +95 -0
- pycodex/tools/request_user_input_tool.py +167 -0
- pycodex/tools/resume_agent_tool.py +56 -0
- pycodex/tools/send_input_tool.py +106 -0
- pycodex/tools/shell_command_tool.py +107 -0
- pycodex/tools/shell_tool.py +112 -0
- pycodex/tools/spawn_agent_tool.py +97 -0
- pycodex/tools/unified_exec_manager.py +380 -0
- pycodex/tools/update_plan_tool.py +79 -0
- pycodex/tools/view_image_tool.py +111 -0
- pycodex/tools/wait_agent_tool.py +75 -0
- pycodex/tools/wait_tool.py +68 -0
- pycodex/tools/web_search_tool.py +30 -0
- pycodex/tools/write_stdin_tool.py +75 -0
- pycodex/utils/__init__.py +40 -0
- pycodex/utils/dotenv.py +64 -0
- pycodex/utils/get_env.py +218 -0
- pycodex/utils/random_ids.py +19 -0
- pycodex/utils/visualize.py +978 -0
- python_codex-0.1.1.dist-info/METADATA +355 -0
- python_codex-0.1.1.dist-info/RECORD +62 -0
- python_codex-0.1.1.dist-info/entry_points.txt +2 -0
- python_codex-0.1.1.dist-info/licenses/LICENSE +201 -0
- python_codex-0.0.1.dist-info/METADATA +0 -30
- python_codex-0.0.1.dist-info/RECORD +0 -4
- {python_codex-0.0.1.dist-info → python_codex-0.1.1.dist-info}/WHEEL +0 -0
pycodex/__init__.py
CHANGED
|
@@ -1,3 +1,142 @@
|
|
|
1
|
-
|
|
1
|
+
from .agent import AgentLoop
|
|
2
|
+
from .context import ContextConfig, ContextManager
|
|
3
|
+
from .model import (
|
|
4
|
+
ModelClient,
|
|
5
|
+
NOOP_MODEL_STREAM_EVENT_HANDLER,
|
|
6
|
+
ResponsesApiError,
|
|
7
|
+
ResponsesModelClient,
|
|
8
|
+
ResponsesProviderConfig,
|
|
9
|
+
)
|
|
10
|
+
from .protocol import (
|
|
11
|
+
AgentEvent,
|
|
12
|
+
AssistantMessage,
|
|
13
|
+
ContextMessage,
|
|
14
|
+
ModelResponse,
|
|
15
|
+
ModelStreamEvent,
|
|
16
|
+
Prompt,
|
|
17
|
+
ReasoningItem,
|
|
18
|
+
Submission,
|
|
19
|
+
ToolCall,
|
|
20
|
+
ToolResult,
|
|
21
|
+
ToolSpec,
|
|
22
|
+
TurnResult,
|
|
23
|
+
UserMessage,
|
|
24
|
+
)
|
|
25
|
+
from .runtime import AgentRuntime
|
|
26
|
+
from .runtime_services import (
|
|
27
|
+
PlanStore,
|
|
28
|
+
RequestPermissionsManager,
|
|
29
|
+
RequestUserInputManager,
|
|
30
|
+
SubAgentManager,
|
|
31
|
+
create_runtime_environment,
|
|
32
|
+
get_runtime_environment,
|
|
33
|
+
)
|
|
34
|
+
from .tools import (
|
|
35
|
+
ApplyPatchTool,
|
|
36
|
+
BaseTool,
|
|
37
|
+
CloseAgentTool,
|
|
38
|
+
CodeModeManager,
|
|
39
|
+
ExecTool,
|
|
40
|
+
ExecCommandTool,
|
|
41
|
+
GrepFilesTool,
|
|
42
|
+
ListDirTool,
|
|
43
|
+
ReadFileTool,
|
|
44
|
+
Registry,
|
|
45
|
+
RequestPermissionsTool,
|
|
46
|
+
RequestUserInputTool,
|
|
47
|
+
ResumeAgentTool,
|
|
48
|
+
SendInputTool,
|
|
49
|
+
ShellCommandTool,
|
|
50
|
+
ShellTool,
|
|
51
|
+
SpawnAgentTool,
|
|
52
|
+
ToolContext,
|
|
53
|
+
ToolRegistry,
|
|
54
|
+
UnifiedExecManager,
|
|
55
|
+
UpdatePlanTool,
|
|
56
|
+
ViewImageTool,
|
|
57
|
+
WaitAgentTool,
|
|
58
|
+
WaitTool,
|
|
59
|
+
WebSearchTool,
|
|
60
|
+
WriteStdinTool,
|
|
61
|
+
)
|
|
2
62
|
|
|
3
|
-
|
|
63
|
+
def debug(stop: bool = False):
|
|
64
|
+
|
|
65
|
+
import socket
|
|
66
|
+
|
|
67
|
+
import debugpy
|
|
68
|
+
|
|
69
|
+
if debugpy.is_client_connected():
|
|
70
|
+
return
|
|
71
|
+
try:
|
|
72
|
+
host = socket.gethostbyname(socket.getfqdn(socket.gethostname()))
|
|
73
|
+
port = 5000
|
|
74
|
+
|
|
75
|
+
debugpy.listen((host, port), in_process_debug_adapter=False)
|
|
76
|
+
print("Waiting for debugger...\a")
|
|
77
|
+
print(f"ip: {host} port:{port}", flush=True)
|
|
78
|
+
debugpy.wait_for_client()
|
|
79
|
+
print("Connected.")
|
|
80
|
+
if stop:
|
|
81
|
+
debugpy.breakpoint()
|
|
82
|
+
except Exception as e:
|
|
83
|
+
import traceback
|
|
84
|
+
|
|
85
|
+
print("\n".join(traceback.format_exception(e)))
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"AgentEvent",
|
|
89
|
+
"AgentLoop",
|
|
90
|
+
"AgentRuntime",
|
|
91
|
+
"ApplyPatchTool",
|
|
92
|
+
"AssistantMessage",
|
|
93
|
+
"BaseTool",
|
|
94
|
+
"CloseAgentTool",
|
|
95
|
+
"create_runtime_environment",
|
|
96
|
+
"CodeModeManager",
|
|
97
|
+
"ContextConfig",
|
|
98
|
+
"ContextManager",
|
|
99
|
+
"ContextMessage",
|
|
100
|
+
"ExecTool",
|
|
101
|
+
"ExecCommandTool",
|
|
102
|
+
"GrepFilesTool",
|
|
103
|
+
"ListDirTool",
|
|
104
|
+
"ModelClient",
|
|
105
|
+
"NOOP_MODEL_STREAM_EVENT_HANDLER",
|
|
106
|
+
"Registry",
|
|
107
|
+
"ReadFileTool",
|
|
108
|
+
"RequestPermissionsTool",
|
|
109
|
+
"RequestUserInputTool",
|
|
110
|
+
"ModelResponse",
|
|
111
|
+
"ModelStreamEvent",
|
|
112
|
+
"PlanStore",
|
|
113
|
+
"Prompt",
|
|
114
|
+
"ReasoningItem",
|
|
115
|
+
"RequestPermissionsManager",
|
|
116
|
+
"RequestUserInputManager",
|
|
117
|
+
"ResumeAgentTool",
|
|
118
|
+
"ResponsesApiError",
|
|
119
|
+
"ResponsesModelClient",
|
|
120
|
+
"ResponsesProviderConfig",
|
|
121
|
+
"SendInputTool",
|
|
122
|
+
"ShellCommandTool",
|
|
123
|
+
"ShellTool",
|
|
124
|
+
"SpawnAgentTool",
|
|
125
|
+
"Submission",
|
|
126
|
+
"SubAgentManager",
|
|
127
|
+
"ToolCall",
|
|
128
|
+
"ToolContext",
|
|
129
|
+
"ToolRegistry",
|
|
130
|
+
"UnifiedExecManager",
|
|
131
|
+
"UpdatePlanTool",
|
|
132
|
+
"ToolResult",
|
|
133
|
+
"ToolSpec",
|
|
134
|
+
"TurnResult",
|
|
135
|
+
"UserMessage",
|
|
136
|
+
"ViewImageTool",
|
|
137
|
+
"WaitAgentTool",
|
|
138
|
+
"WaitTool",
|
|
139
|
+
"WebSearchTool",
|
|
140
|
+
"WriteStdinTool",
|
|
141
|
+
"get_runtime_environment",
|
|
142
|
+
]
|
pycodex/agent.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from .context import ContextManager
|
|
8
|
+
from .model import ModelClient
|
|
9
|
+
from .protocol import (
|
|
10
|
+
AgentEvent,
|
|
11
|
+
AssistantMessage,
|
|
12
|
+
ConversationItem,
|
|
13
|
+
ModelStreamEvent,
|
|
14
|
+
ReasoningItem,
|
|
15
|
+
ToolCall,
|
|
16
|
+
ToolResult,
|
|
17
|
+
TurnResult,
|
|
18
|
+
UserMessage,
|
|
19
|
+
)
|
|
20
|
+
from .tools import ToolContext, ToolRegistry
|
|
21
|
+
from .utils import uuid7_string
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
EventHandler = Callable[[AgentEvent], None]
|
|
25
|
+
NOOP_EVENT_HANDLER: EventHandler = lambda _event: None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TurnInterrupted(RuntimeError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AgentLoop:
|
|
33
|
+
"""Minimal Python port of Codex's turn loop.
|
|
34
|
+
|
|
35
|
+
The core idea mirrors the Rust implementation:
|
|
36
|
+
build a prompt from history, ask the model for output items, run any tool
|
|
37
|
+
calls, append tool results to history, and keep going until the model emits
|
|
38
|
+
a pure assistant response.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
model_client: ModelClient,
|
|
44
|
+
tool_registry: ToolRegistry,
|
|
45
|
+
context_manager: ContextManager | None = None,
|
|
46
|
+
parallel_tool_calls: bool = True,
|
|
47
|
+
event_handler: EventHandler = NOOP_EVENT_HANDLER,
|
|
48
|
+
initial_history: tuple[ConversationItem, ...] = (),
|
|
49
|
+
) -> None:
|
|
50
|
+
self._model_client = model_client
|
|
51
|
+
self._tool_registry = tool_registry
|
|
52
|
+
self._context_manager = context_manager or ContextManager()
|
|
53
|
+
self._parallel_tool_calls = parallel_tool_calls
|
|
54
|
+
self._event_handler = event_handler
|
|
55
|
+
self._history: list[ConversationItem] = list(initial_history)
|
|
56
|
+
self.interrupt_asap = False
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def history(self) -> tuple[ConversationItem, ...]:
|
|
60
|
+
return tuple(self._history)
|
|
61
|
+
|
|
62
|
+
def set_event_handler(
|
|
63
|
+
self, event_handler: EventHandler = NOOP_EVENT_HANDLER
|
|
64
|
+
) -> None:
|
|
65
|
+
self._event_handler = event_handler
|
|
66
|
+
|
|
67
|
+
def _raise_if_interrupt_requested(
|
|
68
|
+
self,
|
|
69
|
+
turn_id: str,
|
|
70
|
+
iteration: int,
|
|
71
|
+
output_text: str | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
if self.interrupt_asap:
|
|
74
|
+
self.interrupt_asap = False
|
|
75
|
+
payload: dict[str, object] = {"iteration": iteration}
|
|
76
|
+
if output_text is not None:
|
|
77
|
+
payload["output_text"] = output_text
|
|
78
|
+
self._emit("turn_interrupted", turn_id, **payload)
|
|
79
|
+
raise TurnInterrupted("turn interrupted")
|
|
80
|
+
|
|
81
|
+
async def run_turn(
|
|
82
|
+
self, texts: list[str], turn_id: str | None = None
|
|
83
|
+
) -> TurnResult:
|
|
84
|
+
turn_id = turn_id or uuid7_string()
|
|
85
|
+
self.interrupt_asap = False
|
|
86
|
+
for text in texts:
|
|
87
|
+
self._history.append(UserMessage(text=text))
|
|
88
|
+
|
|
89
|
+
self._emit(
|
|
90
|
+
"turn_started",
|
|
91
|
+
turn_id,
|
|
92
|
+
user_text="\n".join(texts),
|
|
93
|
+
user_texts=list(texts),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
last_assistant_message: str | None = None
|
|
97
|
+
final_response_items: tuple[
|
|
98
|
+
AssistantMessage | ToolCall | ReasoningItem, ...
|
|
99
|
+
] = ()
|
|
100
|
+
|
|
101
|
+
iteration = 0
|
|
102
|
+
try:
|
|
103
|
+
while True:
|
|
104
|
+
self._raise_if_interrupt_requested(
|
|
105
|
+
turn_id,
|
|
106
|
+
iteration,
|
|
107
|
+
output_text=last_assistant_message,
|
|
108
|
+
)
|
|
109
|
+
iteration += 1
|
|
110
|
+
prompt = self._context_manager.build_prompt(
|
|
111
|
+
self._history,
|
|
112
|
+
self._tool_registry.model_visible_specs(),
|
|
113
|
+
self._parallel_tool_calls,
|
|
114
|
+
turn_id=turn_id,
|
|
115
|
+
)
|
|
116
|
+
self._emit(
|
|
117
|
+
"model_called",
|
|
118
|
+
turn_id,
|
|
119
|
+
iteration=iteration,
|
|
120
|
+
history_size=len(prompt.input),
|
|
121
|
+
tool_count=len(prompt.tools),
|
|
122
|
+
)
|
|
123
|
+
response = await self._model_client.complete(
|
|
124
|
+
prompt,
|
|
125
|
+
lambda event: self._handle_model_stream_event(turn_id, event),
|
|
126
|
+
)
|
|
127
|
+
final_response_items = tuple(response.items)
|
|
128
|
+
self._emit(
|
|
129
|
+
"model_completed",
|
|
130
|
+
turn_id,
|
|
131
|
+
iteration=iteration,
|
|
132
|
+
item_count=len(response.items),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
tool_calls: list[ToolCall] = []
|
|
136
|
+
for item in response.items:
|
|
137
|
+
self._history.append(item)
|
|
138
|
+
if isinstance(item, AssistantMessage):
|
|
139
|
+
last_assistant_message = item.text
|
|
140
|
+
elif isinstance(item, ToolCall):
|
|
141
|
+
tool_calls.append(item)
|
|
142
|
+
|
|
143
|
+
if not tool_calls:
|
|
144
|
+
self._raise_if_interrupt_requested(
|
|
145
|
+
turn_id,
|
|
146
|
+
iteration,
|
|
147
|
+
output_text=last_assistant_message,
|
|
148
|
+
)
|
|
149
|
+
self._emit(
|
|
150
|
+
"turn_completed",
|
|
151
|
+
turn_id,
|
|
152
|
+
iteration=iteration,
|
|
153
|
+
output_text=last_assistant_message,
|
|
154
|
+
)
|
|
155
|
+
return TurnResult(
|
|
156
|
+
turn_id=turn_id,
|
|
157
|
+
output_text=last_assistant_message,
|
|
158
|
+
iterations=iteration,
|
|
159
|
+
response_items=final_response_items,
|
|
160
|
+
history=tuple(self._history),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
tool_results = await self._execute_tool_batch(turn_id, tool_calls)
|
|
164
|
+
self._history.extend(tool_results)
|
|
165
|
+
self._history.extend(self._build_follow_up_messages(tool_results))
|
|
166
|
+
self._raise_if_interrupt_requested(
|
|
167
|
+
turn_id,
|
|
168
|
+
iteration,
|
|
169
|
+
output_text=last_assistant_message,
|
|
170
|
+
)
|
|
171
|
+
except TurnInterrupted:
|
|
172
|
+
raise
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
self._emit(
|
|
175
|
+
"turn_failed",
|
|
176
|
+
turn_id,
|
|
177
|
+
iteration=iteration,
|
|
178
|
+
error=str(exc),
|
|
179
|
+
error_type=type(exc).__name__,
|
|
180
|
+
)
|
|
181
|
+
raise
|
|
182
|
+
|
|
183
|
+
async def _execute_tool_batch(
|
|
184
|
+
self,
|
|
185
|
+
turn_id: str,
|
|
186
|
+
tool_calls: list[ToolCall],
|
|
187
|
+
) -> list[ToolResult]:
|
|
188
|
+
results: list[ToolResult] = []
|
|
189
|
+
parallel_batch: list[ToolCall] = []
|
|
190
|
+
|
|
191
|
+
for call in tool_calls:
|
|
192
|
+
can_run_parallel = (
|
|
193
|
+
self._parallel_tool_calls
|
|
194
|
+
and self._tool_registry.supports_parallel(call.name)
|
|
195
|
+
)
|
|
196
|
+
if can_run_parallel:
|
|
197
|
+
parallel_batch.append(call)
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if parallel_batch:
|
|
201
|
+
prior_results = tuple(results)
|
|
202
|
+
results.extend(
|
|
203
|
+
await asyncio.gather(
|
|
204
|
+
*(
|
|
205
|
+
self._run_single_tool(turn_id, batched_call, prior_results)
|
|
206
|
+
for batched_call in parallel_batch
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
parallel_batch = []
|
|
211
|
+
results.append(await self._run_single_tool(turn_id, call, tuple(results)))
|
|
212
|
+
|
|
213
|
+
if parallel_batch:
|
|
214
|
+
prior_results = tuple(results)
|
|
215
|
+
results.extend(
|
|
216
|
+
await asyncio.gather(
|
|
217
|
+
*(
|
|
218
|
+
self._run_single_tool(turn_id, batched_call, prior_results)
|
|
219
|
+
for batched_call in parallel_batch
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
return results
|
|
224
|
+
|
|
225
|
+
async def _run_single_tool(
|
|
226
|
+
self,
|
|
227
|
+
turn_id: str,
|
|
228
|
+
call: ToolCall,
|
|
229
|
+
prior_results: tuple[ToolResult, ...] = (),
|
|
230
|
+
) -> ToolResult:
|
|
231
|
+
self._emit("tool_started", turn_id, tool_name=call.name, call_id=call.call_id)
|
|
232
|
+
result = await self._tool_registry.execute(
|
|
233
|
+
call,
|
|
234
|
+
ToolContext(
|
|
235
|
+
turn_id=turn_id,
|
|
236
|
+
history=tuple(self._history) + prior_results,
|
|
237
|
+
collaboration_mode=self._context_manager.collaboration_mode,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
payload: dict[str, object] = {
|
|
241
|
+
"tool_name": call.name,
|
|
242
|
+
"call_id": call.call_id,
|
|
243
|
+
"is_error": result.is_error,
|
|
244
|
+
"call": call,
|
|
245
|
+
"result": result,
|
|
246
|
+
}
|
|
247
|
+
self._emit("tool_completed", turn_id, **payload)
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
def _emit(self, kind: str, turn_id: str, **payload: object) -> None:
|
|
251
|
+
self._event_handler(
|
|
252
|
+
AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def _handle_model_stream_event(self, turn_id: str, event: ModelStreamEvent) -> None:
|
|
256
|
+
if event.kind == "assistant_delta":
|
|
257
|
+
self._emit("assistant_delta", turn_id, **event.payload)
|
|
258
|
+
elif event.kind == "tool_call":
|
|
259
|
+
self._emit("tool_called", turn_id, **event.payload)
|
|
260
|
+
|
|
261
|
+
def _build_follow_up_messages(
|
|
262
|
+
self,
|
|
263
|
+
tool_results: list[ToolResult],
|
|
264
|
+
) -> list[UserMessage]:
|
|
265
|
+
follow_ups: list[UserMessage] = []
|
|
266
|
+
for result in tool_results:
|
|
267
|
+
statuses = None
|
|
268
|
+
if (
|
|
269
|
+
result.name == "wait_agent"
|
|
270
|
+
and not result.is_error
|
|
271
|
+
and isinstance(result.output, dict)
|
|
272
|
+
):
|
|
273
|
+
statuses = result.output.get("status")
|
|
274
|
+
if isinstance(statuses, dict):
|
|
275
|
+
for agent_id, status in statuses.items():
|
|
276
|
+
if isinstance(agent_id, str) and isinstance(status, dict):
|
|
277
|
+
payload = {
|
|
278
|
+
"agent_id": agent_id,
|
|
279
|
+
"status": status,
|
|
280
|
+
}
|
|
281
|
+
follow_ups.append(
|
|
282
|
+
UserMessage(
|
|
283
|
+
text=(
|
|
284
|
+
"<subagent_notification>\n"
|
|
285
|
+
f"{json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n"
|
|
286
|
+
"</subagent_notification>"
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
return follow_ups
|