KingNish commited on
Commit
fa6811f
·
1 Parent(s): 714c652

FInal Output tool

Browse files
Files changed (2) hide show
  1. agent/agent.py +107 -14
  2. app.py +23 -11
agent/agent.py CHANGED
@@ -20,24 +20,82 @@ from .tools import Tool
20
 
21
 
22
  class Agent:
23
- """OpenAI-compatible tool-calling agent with streaming."""
24
-
25
- def __init__(self, base_url: str, api_key: str, model: str) -> None:
 
 
 
 
 
 
 
 
 
 
 
26
  self.client = OpenAI(base_url=base_url, api_key=api_key)
27
  self.model = model
28
  self._tools: list[Tool] = []
 
 
 
 
 
 
29
 
30
  def register_tool(self, tool: Tool) -> None:
31
  self._tools.append(tool)
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  def stream(self, messages: list[dict]) -> Generator[dict, None, None]:
34
- """Yield streaming events until the model produces a final response.
35
 
36
  *messages* is mutated in-place — after the generator completes it
37
- contains the full conversation history (including assistant replies,
38
- tool calls, and tool outputs).
39
  """
 
 
40
  while True:
 
 
 
 
 
 
 
 
 
41
  specs = [t.to_openai_spec() for t in self._tools] if self._tools else None
42
 
43
  collected_content = ""
@@ -47,6 +105,7 @@ class Agent:
47
  model=self.model,
48
  messages=messages,
49
  stream=True,
 
50
  )
51
  if specs:
52
  kwargs["tools"] = specs
@@ -67,6 +126,7 @@ class Agent:
67
  # Reasoning (e.g. DeepSeek R1)
68
  reasoning = delta.get("reasoning_content") or delta.get("reasoning")
69
  if reasoning:
 
70
  yield {"type": "reasoning", "content": reasoning}
71
 
72
  # Text content
@@ -95,7 +155,7 @@ class Agent:
95
 
96
  # --- Handle tool calls ---
97
  if collected_tool_calls:
98
- tool_call_list = []
99
  for idx in sorted(collected_tool_calls.keys()):
100
  tc = collected_tool_calls[idx]
101
  tool_call_list.append(
@@ -109,7 +169,8 @@ class Agent:
109
  }
110
  )
111
 
112
- # Assistant message with tool_calls
 
113
  assistant_msg: dict[str, Any] = {
114
  "role": "assistant",
115
  "content": collected_content or None,
@@ -117,7 +178,25 @@ class Agent:
117
  assistant_msg["tool_calls"] = tool_call_list
118
  messages.append(assistant_msg)
119
 
120
- # Execute each tool and append results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  for tc_spec in tool_call_list:
122
  tname = tc_spec["function"]["name"]
123
  try:
@@ -131,7 +210,9 @@ class Agent:
131
  "arguments": tc_spec["function"]["arguments"],
132
  }
133
 
134
- tool_obj = next((t for t in self._tools if t.name == tname), None)
 
 
135
  if tool_obj:
136
  try:
137
  result = tool_obj.run(**targs)
@@ -141,10 +222,13 @@ class Agent:
141
  result = f"Error: Tool '{tname}' not found"
142
 
143
  result_str = str(result)
144
- # if result is too long, truncate and indicate truncation
145
  if len(result_str) > 5_000:
146
  result_str = result_str[:5_000] + "\n...[truncated]"
147
- yield {"type": "tool_output", "name": tname, "content": result_str}
 
 
 
 
148
 
149
  messages.append(
150
  {
@@ -154,9 +238,18 @@ class Agent:
154
  }
155
  )
156
 
157
- continue # Loop back so the model can respond after tools
 
 
 
 
 
 
 
 
 
158
 
159
- # --- No tool callsfinal response ---
160
  messages.append({"role": "assistant", "content": collected_content})
161
  yield {"type": "done", "content": collected_content}
162
  break
 
20
 
21
 
22
  class Agent:
23
+ """OpenAI-compatible tool-calling agent with streaming.
24
+
25
+ When ``register_final_message_tool()`` is used the model **must** call
26
+ ``final_message`` to signal completion — plain text responses without
27
+ the tool will keep the conversation loop alive.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ base_url: str,
33
+ api_key: str,
34
+ model: str,
35
+ max_iterations: int = 15,
36
+ ) -> None:
37
  self.client = OpenAI(base_url=base_url, api_key=api_key)
38
  self.model = model
39
  self._tools: list[Tool] = []
40
+ self._final_tool_name: str | None = None
41
+ self._max_iterations = max_iterations
42
+
43
+ # ------------------------------------------------------------------
44
+ # Tool registration
45
+ # ------------------------------------------------------------------
46
 
47
  def register_tool(self, tool: Tool) -> None:
48
  self._tools.append(tool)
49
 
50
+ def register_final_message_tool(self) -> None:
51
+ """Register a no-input ``final_message`` tool the model **must** call
52
+ to signal that it is done.
53
+
54
+ Until the model calls this tool the agent keeps looping — plain
55
+ text responses or other tool calls will not end the conversation.
56
+
57
+ The tool call is handled internally: no ``tool_call`` /
58
+ ``tool_output`` events are yielded and the caller only sees a
59
+ ``done`` event.
60
+ """
61
+ self._final_tool_name = "final_message"
62
+ self._tools.append(
63
+ Tool(
64
+ name="final_message",
65
+ description=(
66
+ "Signal that you have completed your response and want "
67
+ "to end the conversation. Call this ONLY when you are "
68
+ "truly done. Until you call this tool, the conversation "
69
+ "will continue. Means you will multiple times answer the"
70
+ "same question or can get stuck in loops if you never call it."
71
+ ),
72
+ parameters={"type": "object", "properties": {}, "required": []},
73
+ handler=lambda: "",
74
+ )
75
+ )
76
+
77
+ # ------------------------------------------------------------------
78
+ # Streaming loop
79
+ # ------------------------------------------------------------------
80
+
81
  def stream(self, messages: list[dict]) -> Generator[dict, None, None]:
82
+ """Yield streaming events until the model calls ``final_message``.
83
 
84
  *messages* is mutated in-place — after the generator completes it
85
+ contains the full conversation history.
 
86
  """
87
+ iteration = 0
88
+
89
  while True:
90
+ iteration += 1
91
+ if iteration > self._max_iterations:
92
+ yield {
93
+ "type": "error",
94
+ "content": f"Agent did not call final_message after "
95
+ f"{self._max_iterations} iterations",
96
+ }
97
+ return
98
+
99
  specs = [t.to_openai_spec() for t in self._tools] if self._tools else None
100
 
101
  collected_content = ""
 
105
  model=self.model,
106
  messages=messages,
107
  stream=True,
108
+ extra_body={"thinking_token_budget": 2000}
109
  )
110
  if specs:
111
  kwargs["tools"] = specs
 
126
  # Reasoning (e.g. DeepSeek R1)
127
  reasoning = delta.get("reasoning_content") or delta.get("reasoning")
128
  if reasoning:
129
+ print(reasoning, flush=True, end="")
130
  yield {"type": "reasoning", "content": reasoning}
131
 
132
  # Text content
 
155
 
156
  # --- Handle tool calls ---
157
  if collected_tool_calls:
158
+ tool_call_list: list[dict[str, Any]] = []
159
  for idx in sorted(collected_tool_calls.keys()):
160
  tc = collected_tool_calls[idx]
161
  tool_call_list.append(
 
169
  }
170
  )
171
 
172
+ # Assistant message with tool_calls (appended before we decide
173
+ # whether to continue or stop so the conversation is coherent)
174
  assistant_msg: dict[str, Any] = {
175
  "role": "assistant",
176
  "content": collected_content or None,
 
178
  assistant_msg["tool_calls"] = tool_call_list
179
  messages.append(assistant_msg)
180
 
181
+ # --- final_message check (handled internally) ---
182
+ if self._final_tool_name:
183
+ for tc_spec in tool_call_list:
184
+ if tc_spec["function"]["name"] == self._final_tool_name:
185
+ # Dummy tool result so history stays well-formed
186
+ messages.append(
187
+ {
188
+ "role": "tool",
189
+ "tool_call_id": tc_spec["id"],
190
+ "content": "",
191
+ }
192
+ )
193
+ messages.append(
194
+ {"role": "assistant", "content": collected_content}
195
+ )
196
+ yield {"type": "done", "content": collected_content}
197
+ return
198
+
199
+ # --- Execute real tools ---
200
  for tc_spec in tool_call_list:
201
  tname = tc_spec["function"]["name"]
202
  try:
 
210
  "arguments": tc_spec["function"]["arguments"],
211
  }
212
 
213
+ tool_obj = next(
214
+ (t for t in self._tools if t.name == tname), None
215
+ )
216
  if tool_obj:
217
  try:
218
  result = tool_obj.run(**targs)
 
222
  result = f"Error: Tool '{tname}' not found"
223
 
224
  result_str = str(result)
 
225
  if len(result_str) > 5_000:
226
  result_str = result_str[:5_000] + "\n...[truncated]"
227
+ yield {
228
+ "type": "tool_output",
229
+ "name": tname,
230
+ "content": result_str,
231
+ }
232
 
233
  messages.append(
234
  {
 
238
  }
239
  )
240
 
241
+ continue # Loop back model can call more tools or final_message
242
+
243
+ # --- No tool calls ---
244
+ if self._final_tool_name:
245
+ # final_message is expected but wasn't called — keep the
246
+ # conversation loop alive so the model gets another chance
247
+ messages.append(
248
+ {"role": "assistant", "content": collected_content}
249
+ )
250
+ continue # Loop back
251
 
252
+ # No final_message tool registerednormal end
253
  messages.append({"role": "assistant", "content": collected_content})
254
  yield {"type": "done", "content": collected_content}
255
  break
app.py CHANGED
@@ -15,6 +15,7 @@ agent = Agent(
15
  model=os.getenv("OPENAI_MODEL"),
16
  )
17
  agent.register_tool(FETCH_WEBPAGE_TOOL)
 
18
 
19
  # Load JS from external files
20
  _js_dir = Path(__file__).parent / "static" / "js"
@@ -73,30 +74,34 @@ class GradioEvents:
73
  # Build display as separate titled messages (smolagents style)
74
  display_messages: list[dict] = list(ctx["history"])
75
  text_msg_idx: int | None = None
 
76
  tool_call_idx: int | None = None
77
  spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
78
  spinner_idx = 0
79
- is_reasoning = False
80
 
81
  try:
82
  for ev in agent.stream(ctx["history"]):
83
  t = ev["type"]
84
 
85
  if t == "reasoning":
86
- is_reasoning = True
87
  spinner_idx += 1
88
  content = f"<span class=\"thinking-indicator\">{spinner_frames[spinner_idx % len(spinner_frames)]} Thinking...</span>"
89
- if text_msg_idx is not None:
90
- display_messages[text_msg_idx]["content"] = content
91
  else:
92
  display_messages.append({"role": "assistant", "content": content, "metadata": {}})
93
- text_msg_idx = len(display_messages) - 1
94
 
95
  elif t == "text":
96
- if is_reasoning:
97
- is_reasoning = False
98
- if text_msg_idx is not None:
99
- display_messages[text_msg_idx]["content"] = ""
 
 
 
 
 
100
  if text_msg_idx is not None:
101
  display_messages[text_msg_idx]["content"] += ev["content"]
102
  else:
@@ -104,10 +109,17 @@ class GradioEvents:
104
  text_msg_idx = len(display_messages) - 1
105
 
106
  elif t == "tool_call":
 
 
 
 
 
 
 
107
  # Finalize any in-flight text message (keep if it has real content)
108
  if text_msg_idx is not None:
109
  c = display_messages[text_msg_idx].get("content", "").strip()
110
- if not c or c.startswith("<span"):
111
  display_messages.pop(text_msg_idx)
112
  text_msg_idx = None
113
  tool_call_idx = None
@@ -170,7 +182,7 @@ class GradioEvents:
170
  except Exception as exc:
171
  display_messages.append({
172
  "role": "assistant",
173
- "content": f'<span style="color: var(--color-red-500)">{exc}</span>',
174
  "metadata": {"title": "💥 Error"},
175
  })
176
  yield {
 
15
  model=os.getenv("OPENAI_MODEL"),
16
  )
17
  agent.register_tool(FETCH_WEBPAGE_TOOL)
18
+ agent.register_final_message_tool()
19
 
20
  # Load JS from external files
21
  _js_dir = Path(__file__).parent / "static" / "js"
 
74
  # Build display as separate titled messages (smolagents style)
75
  display_messages: list[dict] = list(ctx["history"])
76
  text_msg_idx: int | None = None
77
+ thinking_msg_idx: int | None = None
78
  tool_call_idx: int | None = None
79
  spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
80
  spinner_idx = 0
 
81
 
82
  try:
83
  for ev in agent.stream(ctx["history"]):
84
  t = ev["type"]
85
 
86
  if t == "reasoning":
 
87
  spinner_idx += 1
88
  content = f"<span class=\"thinking-indicator\">{spinner_frames[spinner_idx % len(spinner_frames)]} Thinking...</span>"
89
+ if thinking_msg_idx is not None:
90
+ display_messages[thinking_msg_idx]["content"] = content
91
  else:
92
  display_messages.append({"role": "assistant", "content": content, "metadata": {}})
93
+ thinking_msg_idx = len(display_messages) - 1
94
 
95
  elif t == "text":
96
+ # Remove spinner message if showing — it was a separate bubble
97
+ if thinking_msg_idx is not None:
98
+ display_messages.pop(thinking_msg_idx)
99
+ thinking_msg_idx = None
100
+ # Re-index since we popped
101
+ if text_msg_idx is not None and thinking_msg_idx is not None:
102
+ if text_msg_idx > thinking_msg_idx:
103
+ text_msg_idx -= 1
104
+
105
  if text_msg_idx is not None:
106
  display_messages[text_msg_idx]["content"] += ev["content"]
107
  else:
 
109
  text_msg_idx = len(display_messages) - 1
110
 
111
  elif t == "tool_call":
112
+ # Remove spinner if present
113
+ if thinking_msg_idx is not None:
114
+ display_messages.pop(thinking_msg_idx)
115
+ if text_msg_idx is not None and text_msg_idx > thinking_msg_idx:
116
+ text_msg_idx -= 1
117
+ thinking_msg_idx = None
118
+
119
  # Finalize any in-flight text message (keep if it has real content)
120
  if text_msg_idx is not None:
121
  c = display_messages[text_msg_idx].get("content", "").strip()
122
+ if not c:
123
  display_messages.pop(text_msg_idx)
124
  text_msg_idx = None
125
  tool_call_idx = None
 
182
  except Exception as exc:
183
  display_messages.append({
184
  "role": "assistant",
185
+ "content": f'<span style="color: var(--color-red-600)">{exc}</span>',
186
  "metadata": {"title": "💥 Error"},
187
  })
188
  yield {