File size: 7,704 Bytes
80c0059
 
 
 
 
 
 
 
 
 
 
 
 
 
 
efb82bb
80c0059
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
efb82bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80c0059
efb82bb
80c0059
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
import random
import inspect
import re

from llama_cpp import Llama


# ------------- TOOLS / FUNCTIONS --------------
# Some of the structure of the agent have been inspired by:
# https://github.com/Pirner/zettelkasten/blob/main/main_notes/1_0_tool_calling_with_llama.py?source=post_page-----23e3d783a6d8---------------------------------------


def get_weather(location: str) -> str:
    """This tool returns the current weather situation.
    Args:
        location: The city or place to chec
    Returns:
        str: Weather situation (e.g. cloudy, rainy, sunny)
    """
    weather_situations = ["cloudy", "rainy", "sunny", "foobar"]
    return random.choice(weather_situations)

def get_temperature(location: str) -> str:
    """This tool returns the current temperature.
    Args:
        location: The city or place to check
    Returns:
        str: Temperature
    """
    temperature = ["-10", "0", "20", "30"]
    return random.choice(temperature)

def get_weather_forecast(location: str, days_ahead: str) -> str:
    """This tool returns the weather forecast for the specified days ahead.
    Args:
        location: The city or place to check
        days_ahead: How many days ahead of today
    Returns:
        str: Weather situation (e.g. cloudy, rainy, sunny)
    """
    test = "Storm"
    if days_ahead > 0:
        test = "Thunderstorm"
    elif days_ahead > 10:
        test = "Hurricane"
    return test


TOOLS = [get_weather, get_temperature, get_weather_forecast]
TOOL_REGISTRY = {f.__name__: f for f in TOOLS}


def function_to_json(func) -> dict:
    """
    Converts a Python function into a JSON-serializable dictionary
    that describes the function's signature, including its name,
    description, and parameters.
    """
    type_map = {
        str: "string",
        int: "integer",
        float: "number",
        bool: "boolean",
        list: "array",
        dict: "object",
        type(None): "null",
    }

    try:
        signature = inspect.signature(func)
    except ValueError as e:
        raise ValueError(
            f"Failed to get signature for function {func.__name__}: {str(e)}"
        )

    parameters = {}
    for param in signature.parameters.values():
        param_type = type_map.get(param.annotation, "string")
        parameters[param.name] = {"type": param_type}

    required = [
        param.name
        for param in signature.parameters.values()
        if param.default == inspect._empty
    ]

    return {
        "type": "function",
        "function": {
            "name": func.__name__,
            "description": func.__doc__ or "",
            "parameters": {
                "type": "object",
                "properties": parameters,
                "required": required,
            },
        },
    }


TOOLS_SCHEMA = [function_to_json(f) for f in TOOLS]


def parse_tool_calls(tool_output: str):
    """
    Very simple parser for outputs like:
    [get_weather(location="Berlin")]
    Returns a list of (func_name, kwargs) tuples.
    """
    calls = []
    # Find patterns like func_name(...)
    for match in re.finditer(r"(\w+)\((.*?)\)", tool_output, re.DOTALL):
        func_name, arg_str = match.groups()
        func_name = func_name.strip()
        kwargs = {}

        arg_str = arg_str.strip()
        if arg_str:
            parts = re.split(r",\s*", arg_str)
            for part in parts:
                if "=" not in part:
                    continue
                key, val = part.split("=", 1)
                key = key.strip()
                val = val.strip().strip('"').strip("'")

                # Try to cast numbers, else keep as string
                try:
                    if "." in val:
                        parsed_val = float(val)
                    else:
                        parsed_val = int(val)
                except ValueError:
                    parsed_val = val
                kwargs[key] = parsed_val

        calls.append((func_name, kwargs))

    return calls


# ------------- HELPER: GENERATION -------------

def generate_non_stream(llm, prompt, max_tokens=256, temperature=0.2, top_p=0.95):
    """One-shot generation for internal agent/tool prompts."""
    out = llm(
        prompt,
        max_tokens=max_tokens,
        temperature=temperature,
        top_p=top_p,
        stop=["User:", "System:"],
        stream=False,
    )
    return out["choices"][0]["text"]


def build_prompt(system_message, history, user_message):
    prompt = f"System: {system_message}\n"
    for turn in history:
        role = turn["role"]
        content = turn["content"]
        prompt += f"{role.capitalize()}: {content}\n"
    prompt += f"User: {user_message}\nAssistant:"
    return prompt


def select_tools_with_llm(llm, user_message: str) -> list:
    """
    Ask the model which tools to call.
    Returns a list of (func_name, kwargs) from parse_tool_calls.
    """
    tool_selection_system = f"""
You are an expert in composing functions.
You are given a user question and a set of possible functions (tools).

Your job is to decide which tools to call and with what arguments.

Rules:
- If you decide to invoke any function(s), you MUST put them in the format:
  [func_name1(param1=value1, param2=value2), func_name2(param1=value1)]
- If none of the functions are suitable, respond with: []
- Do NOT include any explanation or extra text, only the list.
- If the question lacks required parameters, respond with [].

Here is a list of functions in JSON format that you can invoke:
{TOOLS_SCHEMA}
"""

    prompt = (
        f"System: {tool_selection_system}\n"
        f"User: {user_message}\n"
        f"Assistant:"
    )

    raw = generate_non_stream(
        llm,
        prompt,
        max_tokens=256,
        temperature=0.2,
        top_p=0.95,
    )

    return parse_tool_calls(raw)


def call_tools(tool_calls):
    """
    Execute the tools chosen by the model.
    Returns a list of dicts: {name, args, result}.
    """
    results = []
    for func_name, kwargs in tool_calls:
        func = TOOL_REGISTRY.get(func_name)
        if func is None:
            results.append(
                {
                    "name": func_name,
                    "args": kwargs,
                    "result": f"Unknown tool '{func_name}'.",
                }
            )
            continue

        try:
            res = func(**kwargs)
        except Exception as e:
            res = f"Error while calling {func_name}: {e}"

        results.append({"name": func_name, "args": kwargs, "result": res})
    return results


# ------------- CHAT + AGENT LOGIC -------------

def respond(message, history, system_message, llm):
    # ---- 1) Let the model decide if any tools should be used ----
    tool_calls = select_tools_with_llm(llm, message)
    tool_results = call_tools(tool_calls) if tool_calls else []

    # ---- 2) Build final system message including tool results ----
    if tool_results:
        tool_info_str = "\nYou have executed the following tools (name, args, result):\n"
        for tr in tool_results:
            tool_info_str += f"- {tr['name']}({tr['args']}) -> {tr['result']}\n"
        final_system_message = system_message + tool_info_str
    else:
        final_system_message = system_message

    # ---- 3) Use normal chat-style prompt to answer the user ----
    prompt = build_prompt(final_system_message, history, message)

    stream = llm(
        prompt,
        max_tokens=256,
        temperature=0.7,
        top_p=0.9,
        stop=["User:", "System:"],
        stream=True,
    )

    partial = ""
    for out in stream:
        token = out["choices"][0]["text"]
        partial += token
        yield partial