KingNish commited on
Commit
e3b02b7
·
1 Parent(s): 95b1c76

Made agentic

Browse files
Files changed (5) hide show
  1. agent/__init__.py +9 -0
  2. agent/agent.py +162 -0
  3. agent/responses.py +29 -0
  4. agent/tools.py +49 -0
  5. app.py +53 -32
agent/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from .agent import Agent
2
+ from .tools import Tool, fetch_webpage, FETCH_WEBPAGE_TOOL
3
+
4
+ __all__ = [
5
+ "Agent",
6
+ "Tool",
7
+ "fetch_webpage",
8
+ "FETCH_WEBPAGE_TOOL",
9
+ ]
agent/agent.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Any, Generator
3
+
4
+ from openai import OpenAI
5
+
6
+ from .tools import Tool
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Streaming event contract
10
+ # ---------------------------------------------------------------------------
11
+ # Each yield from Agent.stream() is a dict with a "type" key:
12
+ #
13
+ # {"type": "text", "content": "partial text"}
14
+ # {"type": "reasoning","content": "model thinking"}
15
+ # {"type": "tool_call", "name": str, "arguments": '{"url":"..."}'}
16
+ # {"type": "tool_output","name": str, "content": "result"}
17
+ # {"type": "done", "content": "full assistant response"}
18
+ # {"type": "error", "content": "error message"}
19
+ # ---------------------------------------------------------------------------
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 = ""
44
+ collected_tool_calls: dict[int, dict] = {}
45
+
46
+ kwargs: dict[str, Any] = dict(
47
+ model=self.model,
48
+ messages=messages,
49
+ stream=True,
50
+ )
51
+ if specs:
52
+ kwargs["tools"] = specs
53
+
54
+ try:
55
+ stream = self.client.chat.completions.create(**kwargs)
56
+ except Exception as exc:
57
+ yield {"type": "error", "content": str(exc)}
58
+ return
59
+
60
+ for chunk in stream:
61
+ cd = chunk.to_dict()
62
+ choices = cd.get("choices", [])
63
+ if not choices:
64
+ continue
65
+ delta = choices[0].get("delta", {})
66
+
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
73
+ content = delta.get("content", "")
74
+ if content:
75
+ collected_content += content
76
+ yield {"type": "text", "content": content}
77
+
78
+ # Tool call fragments
79
+ for tc in delta.get("tool_calls", []):
80
+ idx = tc.get("index", 0)
81
+ if idx not in collected_tool_calls:
82
+ collected_tool_calls[idx] = {
83
+ "id": tc.get("id", ""),
84
+ "function": {"name": "", "arguments": ""},
85
+ }
86
+ if tc.get("id"):
87
+ collected_tool_calls[idx]["id"] = tc["id"]
88
+ fn = tc.get("function", {})
89
+ if fn.get("name"):
90
+ collected_tool_calls[idx]["function"]["name"] += fn["name"]
91
+ if fn.get("arguments"):
92
+ collected_tool_calls[idx]["function"]["arguments"] += fn[
93
+ "arguments"
94
+ ]
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(
102
+ {
103
+ "id": tc["id"],
104
+ "type": "function",
105
+ "function": {
106
+ "name": tc["function"]["name"],
107
+ "arguments": tc["function"]["arguments"],
108
+ },
109
+ }
110
+ )
111
+
112
+ # Assistant message with tool_calls
113
+ assistant_msg: dict[str, Any] = {
114
+ "role": "assistant",
115
+ "content": collected_content or None,
116
+ }
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:
124
+ targs = json.loads(tc_spec["function"]["arguments"])
125
+ except json.JSONDecodeError:
126
+ targs = {}
127
+
128
+ yield {
129
+ "type": "tool_call",
130
+ "name": tname,
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)
138
+ except Exception as e:
139
+ result = f"Error executing {tname}: {e}"
140
+ else:
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
+ {
151
+ "role": "tool",
152
+ "tool_call_id": tc_spec["id"],
153
+ "content": result_str,
154
+ }
155
+ )
156
+
157
+ continue # Loop back so the model can respond after tools
158
+
159
+ # --- No tool calls — final response ---
160
+ messages.append({"role": "assistant", "content": collected_content})
161
+ yield {"type": "done", "content": collected_content}
162
+ break
agent/responses.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Streaming event types yielded by Agent.stream().
3
+
4
+ Each event is a plain dict with at least a ``"type"`` key.
5
+ """
6
+
7
+ # ── Text chunk ──────────────────────────────────────────────────────────
8
+ # {"type": "text", "content": "partial assistant message"}
9
+ TEXT = "text"
10
+
11
+ # ── Reasoning (e.g. DeepSeek R1 chain-of-thought) ──────────────────────
12
+ # {"type": "reasoning", "content": "model thinking"}
13
+ REASONING = "reasoning"
14
+
15
+ # ── Tool was called by the model ────────────────────────────────────────
16
+ # {"type": "tool_call", "name": "fetch_webpage", "arguments": '{"url":"..."}'}
17
+ TOOL_CALL = "tool_call"
18
+
19
+ # ── Tool execution output ───────────────────────────────────────────────
20
+ # {"type": "tool_output", "name": "fetch_webpage", "content": "<html>..."}
21
+ TOOL_OUTPUT = "tool_output"
22
+
23
+ # ── Final response (no more tool calls) ─────────────────────────────────
24
+ # {"type": "done", "content": "full assistant message"}
25
+ DONE = "done"
26
+
27
+ # ── Fatal error ─────────────────────────────────────────────────────────
28
+ # {"type": "error", "content": "API key invalid"}
29
+ ERROR = "error"
agent/tools.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from markitdown import MarkItDown
2
+ from typing import Any, Callable
3
+
4
+
5
+ class Tool:
6
+ """A callable tool the agent can invoke."""
7
+
8
+ def __init__(
9
+ self, name: str, description: str, parameters: dict, handler: Callable[..., str]
10
+ ) -> None:
11
+ self.name = name
12
+ self.description = description
13
+ self.parameters = parameters
14
+ self.handler = handler
15
+
16
+ def to_openai_spec(self) -> dict:
17
+ return {
18
+ "type": "function",
19
+ "function": {
20
+ "name": self.name,
21
+ "description": self.description,
22
+ "parameters": self.parameters,
23
+ },
24
+ }
25
+
26
+ def run(self, **kwargs: Any) -> str:
27
+ return self.handler(**kwargs)
28
+
29
+
30
+ def fetch_webpage(url: str) -> str:
31
+ """Fetch a webpage and return its text content."""
32
+ md = MarkItDown()
33
+ return md.convert(url).text_content
34
+
35
+ FETCH_WEBPAGE_TOOL = Tool(
36
+ name="fetch_webpage",
37
+ description="Fetch the content of a webpage and return its text",
38
+ parameters={
39
+ "type": "object",
40
+ "properties": {
41
+ "url": {
42
+ "type": "string",
43
+ "description": "The URL to fetch",
44
+ }
45
+ },
46
+ "required": ["url"],
47
+ },
48
+ handler=fetch_webpage,
49
+ )
app.py CHANGED
@@ -1,19 +1,20 @@
1
  import time
2
  import uuid
3
  import gradio as gr
4
- from openai import OpenAI
5
  from dotenv import load_dotenv
 
6
  import os
7
  from pathlib import Path
8
 
9
  load_dotenv()
10
  gr.set_static_paths("static/")
11
 
12
- client = OpenAI(
13
- api_key=os.getenv("OPENAI_API_KEY"),
14
  base_url=os.getenv("OPENAI_BASE_URL"),
 
 
15
  )
16
- model = os.getenv("OPENAI_MODEL")
17
 
18
  # Load JS from external files
19
  _js_dir = Path(__file__).parent / "static" / "js"
@@ -69,54 +70,74 @@ class GradioEvents:
69
 
70
  yield { msg: gr.update(value=""), chatbot: gr.update(value=ctx["history"]), state: gr.update(value=state_value), conv_choice: _conv_choices(state_value), send_btn: gr.update(visible=False), stop_btn: gr.update(visible=True)}
71
 
72
- normal_content = ""
 
73
  is_reasoning = False
74
  spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
75
  spinner_idx = 0
76
- temp = []
77
 
78
  try:
79
- stream = client.chat.completions.create(
80
- model=model,
81
- messages=ctx["history"],
82
- stream=True
83
- )
84
-
85
- for chunk in stream:
86
- chunk = chunk.to_dict()
87
- delta = chunk['choices'][0]['delta']
88
 
89
- if delta.get('reasoning_content') or delta.get('reasoning'):
90
  if not is_reasoning:
91
  is_reasoning = True
92
  spinner_idx += 1
93
- temp = [{
 
 
 
 
94
  "role": "assistant",
95
  "content": f"<span class=\"thinking-indicator\">{spinner_frames[spinner_idx % len(spinner_frames)]} Thinking...</span>",
96
- "metadata": None,
97
- "is_final": False,
98
- }]
99
- elif delta.get('content', ''):
100
  if is_reasoning:
101
  is_reasoning = False
102
- normal_content += delta['content']
103
- temp = [{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  "role": "assistant",
105
- "content": normal_content,
106
- "metadata": None,
107
- "is_final": False,
108
- }]
 
 
 
 
 
 
 
 
 
109
 
110
  yield {
111
  chatbot: gr.update(value=ctx["history"] + temp),
112
  state: gr.update(value=state_value),
113
  }
114
 
115
- if not is_reasoning and temp:
116
- ctx["history"].extend(temp)
117
- if ctx["history"][-1].get("role") == "assistant":
118
- ctx["history"][-1]["is_final"] = True
119
-
120
  yield {
121
  chatbot: gr.update(value=ctx["history"]),
122
  state: gr.update(value=state_value),
 
1
  import time
2
  import uuid
3
  import gradio as gr
 
4
  from dotenv import load_dotenv
5
+ from agent import Agent, FETCH_WEBPAGE_TOOL
6
  import os
7
  from pathlib import Path
8
 
9
  load_dotenv()
10
  gr.set_static_paths("static/")
11
 
12
+ agent = Agent(
 
13
  base_url=os.getenv("OPENAI_BASE_URL"),
14
+ api_key=os.getenv("OPENAI_API_KEY"),
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"
 
70
 
71
  yield { msg: gr.update(value=""), chatbot: gr.update(value=ctx["history"]), state: gr.update(value=state_value), conv_choice: _conv_choices(state_value), send_btn: gr.update(visible=False), stop_btn: gr.update(visible=True)}
72
 
73
+ # Agent will populate ctx["history"] with assistant + tool messages
74
+ display_buffer = ""
75
  is_reasoning = False
76
  spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
77
  spinner_idx = 0
 
78
 
79
  try:
80
+ for ev in agent.stream(ctx["history"]):
81
+ t = ev["type"]
 
 
 
 
 
 
 
82
 
83
+ if t == "reasoning":
84
  if not is_reasoning:
85
  is_reasoning = True
86
  spinner_idx += 1
87
+ if display_buffer:
88
+ temp = [{"role": "assistant", "content": display_buffer}]
89
+ else:
90
+ temp = []
91
+ temp.append({
92
  "role": "assistant",
93
  "content": f"<span class=\"thinking-indicator\">{spinner_frames[spinner_idx % len(spinner_frames)]} Thinking...</span>",
94
+ })
95
+
96
+ elif t == "text":
 
97
  if is_reasoning:
98
  is_reasoning = False
99
+ display_buffer += ev["content"]
100
+ temp = [{"role": "assistant", "content": display_buffer}]
101
+
102
+ elif t == "tool_call":
103
+ display_buffer += (
104
+ f'\n<div class="tool-call">'
105
+ f'🔧 <strong>{ev["name"]}</strong>'
106
+ f'<pre style="display:inline;font-size:0.85em">({ev["arguments"]})</pre>'
107
+ f'</div>'
108
+ )
109
+ temp = [{"role": "assistant", "content": display_buffer}]
110
+
111
+ elif t == "tool_output":
112
+ display_buffer += (
113
+ f'\n<div class="tool-output">'
114
+ f'📄 <em>({len(ev["content"])} chars)</em>'
115
+ f'</div>'
116
+ )
117
+ temp = [{"role": "assistant", "content": display_buffer}]
118
+
119
+ elif t == "error":
120
+ ctx["history"].append({
121
  "role": "assistant",
122
+ "content": f'<span style="color: var(--color-red-500)">{ev["content"]}</span>',
123
+ })
124
+ yield {
125
+ chatbot: gr.update(value=ctx["history"]),
126
+ state: gr.update(value=state_value),
127
+ send_btn: gr.update(visible=True),
128
+ stop_btn: gr.update(visible=False),
129
+ }
130
+ return
131
+
132
+ elif t == "done":
133
+ # Agent has fully populated ctx["history"]
134
+ break
135
 
136
  yield {
137
  chatbot: gr.update(value=ctx["history"] + temp),
138
  state: gr.update(value=state_value),
139
  }
140
 
 
 
 
 
 
141
  yield {
142
  chatbot: gr.update(value=ctx["history"]),
143
  state: gr.update(value=state_value),