Shubadecka commited on
Commit
5965cc0
·
1 Parent(s): f99811b

adding agent

Browse files
Files changed (5) hide show
  1. agent/__init__.py +4 -0
  2. agent/orchestrator.py +166 -0
  3. agent/tools.py +149 -0
  4. app.py +75 -29
  5. requirements.txt +3 -0
agent/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .orchestrator import run_agent
2
+ from .tools import AGENT_TOOLS, dispatch_tool
3
+
4
+ __all__ = ["run_agent", "AGENT_TOOLS", "dispatch_tool"]
agent/orchestrator.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agentic orchestration loop for the VL model.
3
+
4
+ Calls `InferenceClient.chat_completion` with the three agent tools defined in
5
+ `agent/tools.py`. The loop continues as long as the model emits tool calls.
6
+ It stops (and returns a final string) when:
7
+ - The model calls `final_output` -> return the `answer` argument
8
+ - The model calls `abort` -> return "Aborted: {reason}"
9
+ - The model returns plain content with no tool calls
10
+ - `max_tool_rounds` is reached -> return the last assistant content
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Any
17
+
18
+ from huggingface_hub import InferenceClient
19
+
20
+ from .tools import AGENT_TOOLS, dispatch_tool
21
+
22
+ DEFAULT_MAX_TOOL_ROUNDS = 10
23
+
24
+
25
+ def run_agent(
26
+ messages: list[dict[str, Any]],
27
+ client: InferenceClient,
28
+ model: str,
29
+ max_tokens: int = 512,
30
+ temperature: float = 0.7,
31
+ top_p: float = 0.95,
32
+ max_tool_rounds: int = DEFAULT_MAX_TOOL_ROUNDS,
33
+ ) -> str:
34
+ """
35
+ Run the agentic loop and return the final answer as a string.
36
+
37
+ Parameters
38
+ ----------
39
+ messages:
40
+ Full conversation so far, including the system message, all history,
41
+ and the latest user message (which may contain an image as a multimodal
42
+ content list).
43
+ client:
44
+ An authenticated `InferenceClient` instance.
45
+ model:
46
+ HF model ID to use for inference.
47
+ max_tokens:
48
+ Maximum tokens per completion call.
49
+ temperature:
50
+ Sampling temperature.
51
+ top_p:
52
+ Nucleus sampling top-p.
53
+ max_tool_rounds:
54
+ Hard cap on how many tool-calling rounds are allowed before the loop
55
+ gives up and returns whatever the model last said.
56
+ """
57
+ messages = list(messages) # work on a local copy
58
+
59
+ for _round in range(max_tool_rounds + 1):
60
+ response = client.chat_completion(
61
+ messages=messages,
62
+ model=model,
63
+ tools=AGENT_TOOLS,
64
+ tool_choice="auto",
65
+ max_tokens=max_tokens,
66
+ temperature=temperature,
67
+ top_p=top_p,
68
+ stream=False,
69
+ )
70
+
71
+ choice = response.choices[0]
72
+ msg = choice.message
73
+
74
+ tool_calls = getattr(msg, "tool_calls", None) or []
75
+
76
+ # ------------------------------------------------------------------ #
77
+ # No tool calls → plain text answer, we're done #
78
+ # ------------------------------------------------------------------ #
79
+ if not tool_calls:
80
+ return msg.content or ""
81
+
82
+ # ------------------------------------------------------------------ #
83
+ # There are tool calls → check for terminal tools first #
84
+ # ------------------------------------------------------------------ #
85
+ for tc in tool_calls:
86
+ fn = tc.function
87
+ name = fn.name
88
+ raw_args = fn.arguments or "{}"
89
+ args = raw_args if isinstance(raw_args, dict) else _safe_parse(raw_args)
90
+
91
+ if name == "final_output":
92
+ return args.get("answer", msg.content or "")
93
+
94
+ if name == "abort":
95
+ reason = args.get("reason", "")
96
+ return f"Aborted: {reason}" if reason else "Task aborted."
97
+
98
+ # ------------------------------------------------------------------ #
99
+ # Non-terminal tool calls → execute each one and feed results back #
100
+ # ------------------------------------------------------------------ #
101
+ messages.append(_assistant_tool_call_message(msg))
102
+
103
+ for tc in tool_calls:
104
+ fn = tc.function
105
+ name = fn.name
106
+ raw_args = fn.arguments or "{}"
107
+ args = raw_args if isinstance(raw_args, dict) else _safe_parse(raw_args)
108
+
109
+ result = dispatch_tool(name, args)
110
+
111
+ messages.append(
112
+ {
113
+ "role": "tool",
114
+ "content": result,
115
+ "tool_call_id": tc.id,
116
+ }
117
+ )
118
+
119
+ # Max rounds exhausted — return the last assistant content if any
120
+ last_assistant = next(
121
+ (m["content"] for m in reversed(messages) if m.get("role") == "assistant"),
122
+ "I was unable to complete the task within the allowed number of steps.",
123
+ )
124
+ return last_assistant or "I was unable to complete the task within the allowed number of steps."
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Helpers
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def _safe_parse(raw: str) -> dict:
132
+ """Parse a JSON string into a dict, returning {} on failure."""
133
+ try:
134
+ return json.loads(raw)
135
+ except (json.JSONDecodeError, TypeError):
136
+ return {}
137
+
138
+
139
+ def _assistant_tool_call_message(msg: Any) -> dict:
140
+ """
141
+ Re-serialise the assistant message that contains tool_calls into the plain
142
+ dict format expected when appended back to `messages`.
143
+ """
144
+ tool_calls_payload = []
145
+ for tc in msg.tool_calls or []:
146
+ fn = tc.function
147
+ tool_calls_payload.append(
148
+ {
149
+ "id": tc.id,
150
+ "type": "function",
151
+ "function": {
152
+ "name": fn.name,
153
+ "arguments": (
154
+ fn.arguments
155
+ if isinstance(fn.arguments, str)
156
+ else json.dumps(fn.arguments)
157
+ ),
158
+ },
159
+ }
160
+ )
161
+
162
+ return {
163
+ "role": "assistant",
164
+ "content": msg.content or "",
165
+ "tool_calls": tool_calls_payload,
166
+ }
agent/tools.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tool definitions (HF/OpenAI schema) and dispatcher for the VL agent.
3
+
4
+ Three tools:
5
+ - search_web: search the internet and return snippets
6
+ - final_output: signal the agent loop to stop and return an answer
7
+ - abort: signal the agent loop to stop and report a failure/reason
8
+ """
9
+
10
+ import json
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Tool schemas (passed to InferenceClient.chat_completion(tools=...))
14
+ # ---------------------------------------------------------------------------
15
+
16
+ AGENT_TOOLS = [
17
+ {
18
+ "type": "function",
19
+ "function": {
20
+ "name": "search_web",
21
+ "description": (
22
+ "Search the internet for up-to-date information. "
23
+ "Use this when you need current facts, news, or data that you are "
24
+ "not confident about from your training knowledge."
25
+ ),
26
+ "parameters": {
27
+ "type": "object",
28
+ "properties": {
29
+ "query": {
30
+ "type": "string",
31
+ "description": "The search query to look up on the web.",
32
+ }
33
+ },
34
+ "required": ["query"],
35
+ },
36
+ },
37
+ },
38
+ {
39
+ "type": "function",
40
+ "function": {
41
+ "name": "final_output",
42
+ "description": (
43
+ "Deliver the final answer to the user. "
44
+ "Call this once you have gathered enough information and are ready "
45
+ "to give a complete, accurate response. The 'answer' field will be "
46
+ "shown directly to the user."
47
+ ),
48
+ "parameters": {
49
+ "type": "object",
50
+ "properties": {
51
+ "answer": {
52
+ "type": "string",
53
+ "description": "The complete final answer to present to the user.",
54
+ }
55
+ },
56
+ "required": ["answer"],
57
+ },
58
+ },
59
+ },
60
+ {
61
+ "type": "function",
62
+ "function": {
63
+ "name": "abort",
64
+ "description": (
65
+ "Abort the current task when it cannot be completed. "
66
+ "Use this if the task is impossible, unsafe, or the user asked to stop."
67
+ ),
68
+ "parameters": {
69
+ "type": "object",
70
+ "properties": {
71
+ "reason": {
72
+ "type": "string",
73
+ "description": "Explanation of why the task is being aborted.",
74
+ }
75
+ },
76
+ "required": [],
77
+ },
78
+ },
79
+ },
80
+ ]
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Tool implementations
85
+ # ---------------------------------------------------------------------------
86
+
87
+ def _search_web(query: str, max_results: int = 5) -> str:
88
+ """Run a DuckDuckGo text search and return formatted snippets."""
89
+ try:
90
+ from duckduckgo_search import DDGS
91
+
92
+ results = []
93
+ with DDGS() as ddgs:
94
+ for r in ddgs.text(query, max_results=max_results):
95
+ title = r.get("title", "")
96
+ body = r.get("body", "")
97
+ href = r.get("href", "")
98
+ results.append(f"**{title}**\n{body}\nSource: {href}")
99
+
100
+ if not results:
101
+ return "No results found for the given query."
102
+
103
+ return "\n\n---\n\n".join(results)
104
+
105
+ except Exception as exc: # noqa: BLE001
106
+ return f"Search failed: {exc}"
107
+
108
+
109
+ def _final_output(answer: str) -> str:
110
+ """No-op implementation; the orchestrator reads 'answer' directly."""
111
+ return "Answer delivered."
112
+
113
+
114
+ def _abort(reason: str = "") -> str:
115
+ """No-op implementation; the orchestrator reads 'reason' directly."""
116
+ return f"Aborted: {reason}" if reason else "Task aborted."
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Dispatcher
121
+ # ---------------------------------------------------------------------------
122
+
123
+ def dispatch_tool(tool_name: str, arguments: dict | str) -> str:
124
+ """
125
+ Call the named tool with the given arguments and return the result string.
126
+
127
+ `arguments` may arrive as a JSON string (from the model) or already as a dict.
128
+ """
129
+ if isinstance(arguments, str):
130
+ try:
131
+ arguments = json.loads(arguments)
132
+ except json.JSONDecodeError:
133
+ arguments = {}
134
+
135
+ if tool_name == "search_web":
136
+ query = arguments.get("query", "")
137
+ if not query:
138
+ return "Error: 'query' parameter is required for search_web."
139
+ return _search_web(query)
140
+
141
+ if tool_name == "final_output":
142
+ answer = arguments.get("answer", "")
143
+ return _final_output(answer)
144
+
145
+ if tool_name == "abort":
146
+ reason = arguments.get("reason", "")
147
+ return _abort(reason)
148
+
149
+ return f"Error: unknown tool '{tool_name}'."
app.py CHANGED
@@ -1,52 +1,98 @@
 
 
 
 
 
1
  import gradio as gr
2
  from huggingface_hub import InferenceClient
3
 
 
4
 
5
- def respond(
6
- message,
7
- history: list[dict[str, str]],
8
- system_message,
9
- max_tokens,
10
- temperature,
11
- top_p,
12
- hf_token: gr.OAuthToken,
13
- ):
 
 
 
 
 
 
 
 
 
 
 
 
14
  """
15
- For more information on `huggingface_hub` Inference API support, please check the docs: https://huggingface.co/docs/huggingface_hub/v0.22.2/en/guides/inference
 
 
 
16
  """
17
- client = InferenceClient(token=hf_token.token, model="openai/gpt-oss-20b")
 
18
 
19
- messages = [{"role": "system", "content": system_message}]
 
 
 
 
 
 
 
 
 
20
 
21
- messages.extend(history)
22
 
23
- messages.append({"role": "user", "content": message})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
- response = ""
 
26
 
27
- for message in client.chat_completion(
28
- messages,
 
 
 
29
  max_tokens=max_tokens,
30
- stream=True,
31
  temperature=temperature,
32
  top_p=top_p,
33
- ):
34
- choices = message.choices
35
- token = ""
36
- if len(choices) and choices[0].delta.content:
37
- token = choices[0].delta.content
38
 
39
- response += token
40
- yield response
 
 
 
 
41
 
42
 
43
- """
44
- For information on how to customize the ChatInterface, peruse the gradio docs: https://www.gradio.app/docs/chatinterface
45
- """
46
  chatbot = gr.ChatInterface(
47
  respond,
 
48
  additional_inputs=[
49
- gr.Textbox(value="You are a friendly Chatbot.", label="System message"),
 
50
  gr.Slider(minimum=1, maximum=2048, value=512, step=1, label="Max new tokens"),
51
  gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
52
  gr.Slider(
 
1
+ import base64
2
+ import mimetypes
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
  import gradio as gr
7
  from huggingface_hub import InferenceClient
8
 
9
+ from agent import run_agent
10
 
11
+ MODEL = "openai/gpt-oss-20b"
12
+
13
+ DEFAULT_SYSTEM = (
14
+ "You are a helpful, multimodal AI assistant. "
15
+ "You can analyse images the user uploads and answer questions about them. "
16
+ "When you need up-to-date information from the internet, use the search_web tool. "
17
+ "Once you have a complete answer, call final_output with the full answer. "
18
+ "If a task is impossible or unsafe, call abort with a brief reason."
19
+ )
20
+
21
+
22
+ def _image_to_data_url(image_path: str) -> str:
23
+ """Convert a local image file path to a base64 data URL."""
24
+ path = Path(image_path)
25
+ mime, _ = mimetypes.guess_type(str(path))
26
+ mime = mime or "image/jpeg"
27
+ data = base64.b64encode(path.read_bytes()).decode("utf-8")
28
+ return f"data:{mime};base64,{data}"
29
+
30
+
31
+ def _build_user_content(text: str, image_path: str | None) -> Any:
32
  """
33
+ Build the `content` field for a user message.
34
+
35
+ Returns a plain string when there is no image, or a list of content parts
36
+ (text + image_url) when an image is present.
37
  """
38
+ if not image_path:
39
+ return text or ""
40
 
41
+ parts: list[dict] = []
42
+ if text:
43
+ parts.append({"type": "text", "text": text})
44
+ parts.append(
45
+ {
46
+ "type": "image_url",
47
+ "image_url": {"url": _image_to_data_url(image_path)},
48
+ }
49
+ )
50
+ return parts
51
 
 
52
 
53
+ def respond(
54
+ message: str,
55
+ history: list[dict],
56
+ image: str | None,
57
+ system_message: str,
58
+ max_tokens: int,
59
+ temperature: float,
60
+ top_p: float,
61
+ hf_token: gr.OAuthToken,
62
+ ):
63
+ client = InferenceClient(token=hf_token.token, model=MODEL)
64
+
65
+ # Build the full message list: system + history + new user turn
66
+ messages: list[dict] = [{"role": "system", "content": system_message}]
67
+ messages.extend(history)
68
 
69
+ user_content = _build_user_content(message, image)
70
+ messages.append({"role": "user", "content": user_content})
71
 
72
+ # Run the agentic loop and get back the final answer string
73
+ answer = run_agent(
74
+ messages=messages,
75
+ client=client,
76
+ model=MODEL,
77
  max_tokens=max_tokens,
 
78
  temperature=temperature,
79
  top_p=top_p,
80
+ )
 
 
 
 
81
 
82
+ # Yield in chunks to preserve Gradio's streaming UX
83
+ chunk_size = 8
84
+ partial = ""
85
+ for i in range(0, len(answer), chunk_size):
86
+ partial += answer[i : i + chunk_size]
87
+ yield partial
88
 
89
 
 
 
 
90
  chatbot = gr.ChatInterface(
91
  respond,
92
+ type="messages",
93
  additional_inputs=[
94
+ gr.Image(label="Upload image (optional)", type="filepath"),
95
+ gr.Textbox(value=DEFAULT_SYSTEM, label="System message"),
96
  gr.Slider(minimum=1, maximum=2048, value=512, step=1, label="Max new tokens"),
97
  gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
98
  gr.Slider(
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio>=5.0.0
2
+ huggingface_hub>=0.25.0
3
+ duckduckgo-search>=6.0.0