Shubadecka commited on
Commit
c2ff08b
·
2 Parent(s): 1d3370f522b36f

merge with main

Browse files
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/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (272 Bytes). View file
 
agent/__pycache__/orchestrator.cpython-310.pyc ADDED
Binary file (4.03 kB). View file
 
agent/__pycache__/tools.cpython-310.pyc ADDED
Binary file (3.29 kB). View file
 
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,5 +1,6 @@
1
  import base64
2
- import io
 
3
 
4
  import gradio as gr
5
  from huggingface_hub import InferenceClient
@@ -18,71 +19,124 @@ def _image_to_data_url(image_path: str, max_side: int = 1120, quality: int = 85)
18
  img.save(buf, format="JPEG", quality=quality)
19
  return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  def respond(
23
- message,
24
  history: list[dict],
25
- system_message,
26
- max_tokens,
27
- temperature,
28
- top_p,
 
29
  hf_token: gr.OAuthToken,
30
  ):
31
- client = InferenceClient(token=hf_token.token, model="Qwen/Qwen3-VL-30B-A3B-Thinking")
32
 
33
- messages = [{"role": "system", "content": system_message}]
 
34
  messages.extend(history)
35
 
36
- # message is a dict {"text": str, "files": [path, ...]} from MultimodalTextbox
37
- text = message.get("text", "") if isinstance(message, dict) else message
38
- files = message.get("files", []) if isinstance(message, dict) else []
39
-
40
- if files:
41
- content = []
42
- if text:
43
- content.append({"type": "text", "text": text})
44
- for f in files:
45
- content.append({"type": "image_url", "image_url": {"url": _image_to_data_url(f)}})
46
- else:
47
- content = text
48
 
49
- messages.append({"role": "user", "content": content})
50
-
51
- response = ""
52
- for chunk in client.chat_completion(
53
- messages,
54
  max_tokens=max_tokens,
55
- stream=True,
56
  temperature=temperature,
57
  top_p=top_p,
58
- ):
59
- choices = chunk.choices
60
- if choices and choices[0].delta.content:
61
- response += choices[0].delta.content
62
- yield response
63
-
64
-
65
- chatbot = gr.ChatInterface(
66
- respond,
67
- multimodal=True,
68
- additional_inputs=[
69
- gr.Textbox(value="You are a friendly Chatbot.", label="System message"),
70
- gr.Slider(minimum=1, maximum=16384, value=8192, step=1, label="Max new tokens"),
71
- gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
72
- gr.Slider(
73
- minimum=0.1,
74
- maximum=1.0,
75
- value=0.95,
76
- step=0.05,
77
- label="Top-p (nucleus sampling)",
78
- ),
79
- ],
80
- )
81
 
82
  with gr.Blocks() as demo:
83
  with gr.Sidebar():
84
  gr.LoginButton()
85
- chatbot.render()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
 
88
  if __name__ == "__main__":
 
1
  import base64
2
+ from pathlib import Path
3
+ from typing import Any
4
 
5
  import gradio as gr
6
  from huggingface_hub import InferenceClient
 
19
  img.save(buf, format="JPEG", quality=quality)
20
  return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
21
 
22
+ from agent import run_agent
23
+
24
+ MODEL = "Qwen/Qwen3-VL-30B-A3B-Thinking"
25
+
26
+ DEFAULT_SYSTEM = (
27
+ "You are a helpful, multimodal AI assistant. "
28
+ "You can analyse images the user uploads and answer questions about them. "
29
+ "When you need up-to-date information from the internet, use the search_web tool. "
30
+ "Once you have a complete answer, call final_output with the full answer. "
31
+ "If a task is impossible or unsafe, call abort with a brief reason."
32
+ )
33
+
34
+
35
+ def _image_to_data_url(image_path: str, max_side: int = 1120, quality: int = 85) -> str:
36
+ """
37
+ Convert a local image file to a base64 data URL, resizing it first so the
38
+ payload stays within the HF router's request-size limit.
39
+
40
+ Images are downscaled so their longest side is at most `max_side` pixels,
41
+ then saved as JPEG at `quality` to keep the base64 size small.
42
+ """
43
+ from PIL import Image
44
+ import io
45
+
46
+ with Image.open(image_path) as img:
47
+ img = img.convert("RGB")
48
+ w, h = img.size
49
+ if max(w, h) > max_side:
50
+ scale = max_side / max(w, h)
51
+ img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
52
+
53
+ buf = io.BytesIO()
54
+ img.save(buf, format="JPEG", quality=quality)
55
+ data = base64.b64encode(buf.getvalue()).decode("utf-8")
56
+
57
+ return f"data:image/jpeg;base64,{data}"
58
+
59
+
60
+ def _build_user_content(text: str, image_path: str | None) -> Any:
61
+ """
62
+ Build the `content` field for a user message.
63
+
64
+ Returns a plain string when there is no image, or a list of content parts
65
+ (text + image_url) when an image is present.
66
+ """
67
+ if not image_path:
68
+ return text or ""
69
+
70
+ parts: list[dict] = []
71
+ if text:
72
+ parts.append({"type": "text", "text": text})
73
+ parts.append(
74
+ {
75
+ "type": "image_url",
76
+ "image_url": {"url": _image_to_data_url(image_path)},
77
+ }
78
+ )
79
+ return parts
80
+
81
 
82
  def respond(
83
+ message: str,
84
  history: list[dict],
85
+ image: str | None,
86
+ system_message: str,
87
+ max_tokens: int,
88
+ temperature: float,
89
+ top_p: float,
90
  hf_token: gr.OAuthToken,
91
  ):
92
+ client = InferenceClient(token=hf_token.token, model=MODEL)
93
 
94
+ # Build the full message list: system + history + new user turn
95
+ messages: list[dict] = [{"role": "system", "content": system_message}]
96
  messages.extend(history)
97
 
98
+ user_content = _build_user_content(message, image)
99
+ messages.append({"role": "user", "content": user_content})
 
 
 
 
 
 
 
 
 
 
100
 
101
+ # Run the agentic loop and get back the final answer string
102
+ answer = run_agent(
103
+ messages=messages,
104
+ client=client,
105
+ model=MODEL,
106
  max_tokens=max_tokens,
 
107
  temperature=temperature,
108
  top_p=top_p,
109
+ )
110
+
111
+ # Yield in chunks to preserve Gradio's streaming UX
112
+ chunk_size = 8
113
+ partial = ""
114
+ for i in range(0, len(answer), chunk_size):
115
+ partial += answer[i : i + chunk_size]
116
+ yield partial
117
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
  with gr.Blocks() as demo:
120
  with gr.Sidebar():
121
  gr.LoginButton()
122
+ image_input = gr.Image(label="Upload image", type="filepath")
123
+
124
+ gr.ChatInterface(
125
+ respond,
126
+ additional_inputs=[
127
+ image_input,
128
+ gr.Textbox(value=DEFAULT_SYSTEM, label="System message"),
129
+ gr.Slider(minimum=1, maximum=2048, value=512, step=1, label="Max new tokens"),
130
+ gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
131
+ gr.Slider(
132
+ minimum=0.1,
133
+ maximum=1.0,
134
+ value=0.95,
135
+ step=0.05,
136
+ label="Top-p (nucleus sampling)",
137
+ ),
138
+ ],
139
+ )
140
 
141
 
142
  if __name__ == "__main__":
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio>=5.0.0
2
+ huggingface_hub>=0.25.0
3
+ duckduckgo-search>=6.0.0