langroid 0.58.2__py3-none-any.whl → 0.59.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langroid/agent/base.py +39 -17
- langroid/agent/callbacks/chainlit.py +2 -1
- langroid/agent/chat_agent.py +73 -55
- langroid/agent/chat_document.py +7 -7
- langroid/agent/done_sequence_parser.py +46 -11
- langroid/agent/openai_assistant.py +9 -9
- langroid/agent/special/arangodb/arangodb_agent.py +10 -18
- langroid/agent/special/arangodb/tools.py +3 -3
- langroid/agent/special/doc_chat_agent.py +16 -14
- langroid/agent/special/lance_rag/critic_agent.py +2 -2
- langroid/agent/special/lance_rag/query_planner_agent.py +4 -4
- langroid/agent/special/lance_tools.py +6 -5
- langroid/agent/special/neo4j/neo4j_chat_agent.py +3 -7
- langroid/agent/special/relevance_extractor_agent.py +1 -1
- langroid/agent/special/sql/sql_chat_agent.py +11 -3
- langroid/agent/task.py +53 -94
- langroid/agent/tool_message.py +33 -17
- langroid/agent/tools/file_tools.py +4 -2
- langroid/agent/tools/mcp/fastmcp_client.py +19 -6
- langroid/agent/tools/orchestration.py +22 -17
- langroid/agent/tools/recipient_tool.py +3 -3
- langroid/agent/tools/task_tool.py +22 -16
- langroid/agent/xml_tool_message.py +90 -35
- langroid/cachedb/base.py +1 -1
- langroid/embedding_models/base.py +2 -2
- langroid/embedding_models/models.py +3 -7
- langroid/exceptions.py +4 -1
- langroid/language_models/azure_openai.py +2 -2
- langroid/language_models/base.py +6 -4
- langroid/language_models/client_cache.py +64 -0
- langroid/language_models/config.py +2 -4
- langroid/language_models/model_info.py +9 -1
- langroid/language_models/openai_gpt.py +119 -20
- langroid/language_models/provider_params.py +3 -22
- langroid/mytypes.py +11 -4
- langroid/parsing/code_parser.py +1 -1
- langroid/parsing/file_attachment.py +1 -1
- langroid/parsing/md_parser.py +14 -4
- langroid/parsing/parser.py +22 -7
- langroid/parsing/repo_loader.py +3 -1
- langroid/parsing/search.py +1 -1
- langroid/parsing/url_loader.py +17 -51
- langroid/parsing/urls.py +5 -4
- langroid/prompts/prompts_config.py +1 -1
- langroid/pydantic_v1/__init__.py +61 -4
- langroid/pydantic_v1/main.py +10 -4
- langroid/utils/configuration.py +13 -11
- langroid/utils/constants.py +1 -1
- langroid/utils/globals.py +21 -5
- langroid/utils/html_logger.py +2 -1
- langroid/utils/object_registry.py +1 -1
- langroid/utils/pydantic_utils.py +55 -28
- langroid/utils/types.py +2 -2
- langroid/vector_store/base.py +3 -3
- langroid/vector_store/lancedb.py +5 -5
- langroid/vector_store/meilisearch.py +2 -2
- langroid/vector_store/pineconedb.py +4 -4
- langroid/vector_store/postgres.py +1 -1
- langroid/vector_store/qdrantdb.py +3 -3
- langroid/vector_store/weaviatedb.py +1 -1
- {langroid-0.58.2.dist-info → langroid-0.59.0.dist-info}/METADATA +3 -2
- {langroid-0.58.2.dist-info → langroid-0.59.0.dist-info}/RECORD +64 -64
- {langroid-0.58.2.dist-info → langroid-0.59.0.dist-info}/WHEEL +0 -0
- {langroid-0.58.2.dist-info → langroid-0.59.0.dist-info}/licenses/LICENSE +0 -0
langroid/agent/task.py
CHANGED
@@ -27,6 +27,7 @@ from typing import (
|
|
27
27
|
)
|
28
28
|
|
29
29
|
import numpy as np
|
30
|
+
from pydantic import BaseModel, ConfigDict
|
30
31
|
from rich import print
|
31
32
|
from rich.markup import escape
|
32
33
|
|
@@ -45,7 +46,6 @@ from langroid.exceptions import InfiniteLoopException
|
|
45
46
|
from langroid.mytypes import Entity
|
46
47
|
from langroid.parsing.parse_json import extract_top_level_json
|
47
48
|
from langroid.parsing.routing import parse_addressed_message
|
48
|
-
from langroid.pydantic_v1 import BaseModel
|
49
49
|
from langroid.utils.configuration import settings
|
50
50
|
from langroid.utils.constants import (
|
51
51
|
DONE,
|
@@ -90,6 +90,9 @@ class AgentEvent(BaseModel):
|
|
90
90
|
|
91
91
|
event_type: EventType
|
92
92
|
tool_name: Optional[str] = None # For SPECIFIC_TOOL
|
93
|
+
tool_class: Optional[Type[Any]] = (
|
94
|
+
None # For storing tool class references when using SPECIFIC_TOOL events
|
95
|
+
)
|
93
96
|
content_pattern: Optional[str] = None # For CONTENT_MATCH (regex)
|
94
97
|
responder: Optional[str] = None # Specific responder name
|
95
98
|
# Optionally match only if the responder was specific entity/task
|
@@ -146,6 +149,7 @@ class TaskConfig(BaseModel):
|
|
146
149
|
done_sequences (List[DoneSequence]): List of event sequences that trigger task
|
147
150
|
completion. Task is done if ANY sequence matches the recent event history.
|
148
151
|
Each sequence is checked against the message parent chain.
|
152
|
+
Tool classes can be referenced in sequences like "T[MyToolClass]".
|
149
153
|
|
150
154
|
"""
|
151
155
|
|
@@ -298,14 +302,8 @@ class Task:
|
|
298
302
|
set_parent_agent=noop_fn,
|
299
303
|
)
|
300
304
|
self.config = config
|
301
|
-
# Store parsed done sequences
|
305
|
+
# Store parsed done sequences (will be initialized after agent assignment)
|
302
306
|
self._parsed_done_sequences: Optional[List[DoneSequence]] = None
|
303
|
-
if self.config.done_sequences:
|
304
|
-
from .done_sequence_parser import parse_done_sequences
|
305
|
-
|
306
|
-
self._parsed_done_sequences = parse_done_sequences(
|
307
|
-
self.config.done_sequences
|
308
|
-
)
|
309
307
|
# how to behave as a sub-task; can be overridden by `add_sub_task()`
|
310
308
|
self.config_sub_task = copy.deepcopy(config)
|
311
309
|
# counts of distinct pending messages in history,
|
@@ -340,6 +338,21 @@ class Task:
|
|
340
338
|
self.agent.set_system_message(system_message)
|
341
339
|
if user_message:
|
342
340
|
self.agent.set_user_message(user_message)
|
341
|
+
|
342
|
+
# Initialize parsed done sequences now that self.agent is available
|
343
|
+
if self.config.done_sequences:
|
344
|
+
from .done_sequence_parser import parse_done_sequences
|
345
|
+
|
346
|
+
# Pass agent's llm_tools_map directly
|
347
|
+
tools_map = (
|
348
|
+
self.agent.llm_tools_map
|
349
|
+
if hasattr(self.agent, "llm_tools_map")
|
350
|
+
else None
|
351
|
+
)
|
352
|
+
self._parsed_done_sequences = parse_done_sequences(
|
353
|
+
self.config.done_sequences, tools_map
|
354
|
+
)
|
355
|
+
|
343
356
|
self.max_cost: float = 0
|
344
357
|
self.max_tokens: int = 0
|
345
358
|
self.session_id: str = ""
|
@@ -483,7 +496,7 @@ class Task:
|
|
483
496
|
self.message_counter.clear()
|
484
497
|
# create a unique string that will not likely be in any message,
|
485
498
|
# so we always have a message with count=1
|
486
|
-
self.message_counter.update([hash("___NO_MESSAGE___")])
|
499
|
+
self.message_counter.update([str(hash("___NO_MESSAGE___"))])
|
487
500
|
|
488
501
|
def _cache_session_store(self, key: str, value: str) -> None:
|
489
502
|
"""
|
@@ -2083,7 +2096,7 @@ class Task:
|
|
2083
2096
|
"""
|
2084
2097
|
from langroid.agent.chat_document import ChatDocLoggerFields
|
2085
2098
|
|
2086
|
-
default_values = ChatDocLoggerFields().
|
2099
|
+
default_values = ChatDocLoggerFields().model_dump().values()
|
2087
2100
|
msg_str_tsv = "\t".join(str(v) for v in default_values)
|
2088
2101
|
if msg is not None:
|
2089
2102
|
msg_str_tsv = msg.tsv_str()
|
@@ -2144,7 +2157,7 @@ class Task:
|
|
2144
2157
|
else:
|
2145
2158
|
# Get fields from the message
|
2146
2159
|
fields = msg.log_fields()
|
2147
|
-
fields_dict = fields.
|
2160
|
+
fields_dict = fields.model_dump()
|
2148
2161
|
fields_dict.update(
|
2149
2162
|
{
|
2150
2163
|
"responder": str(resp),
|
@@ -2155,13 +2168,11 @@ class Task:
|
|
2155
2168
|
|
2156
2169
|
# Create a ChatDocLoggerFields-like object for the HTML logger
|
2157
2170
|
# Create a simple BaseModel subclass dynamically
|
2158
|
-
from
|
2171
|
+
from pydantic import BaseModel
|
2159
2172
|
|
2160
2173
|
class LogFields(BaseModel):
|
2161
|
-
|
2162
|
-
extra = "allow" # Allow extra fields
|
2174
|
+
model_config = ConfigDict(extra="allow") # Allow extra fields
|
2163
2175
|
|
2164
|
-
# Create instance with the fields from fields_dict
|
2165
2176
|
log_obj = LogFields(**fields_dict)
|
2166
2177
|
self.html_logger.log(log_obj)
|
2167
2178
|
|
@@ -2173,8 +2184,6 @@ class Task:
|
|
2173
2184
|
"""
|
2174
2185
|
if recipient == "":
|
2175
2186
|
return True
|
2176
|
-
# native responders names are USER, LLM, AGENT,
|
2177
|
-
# and the names of subtasks are from Task.name attribute
|
2178
2187
|
responder_names = [self.name.lower()] + [
|
2179
2188
|
r.name.lower() for r in self.responders
|
2180
2189
|
]
|
@@ -2184,7 +2193,6 @@ class Task:
|
|
2184
2193
|
"""
|
2185
2194
|
Is the recipient explicitly specified and does not match responder "e" ?
|
2186
2195
|
"""
|
2187
|
-
# Note that recipient could be specified as an Entity or a Task name
|
2188
2196
|
return (
|
2189
2197
|
self.pending_message is not None
|
2190
2198
|
and (recipient := self.pending_message.metadata.recipient) != ""
|
@@ -2195,8 +2203,6 @@ class Task:
|
|
2195
2203
|
|
2196
2204
|
def _user_can_respond(self) -> bool:
|
2197
2205
|
return self.interactive or (
|
2198
|
-
# regardless of self.interactive, if a msg is explicitly addressed to
|
2199
|
-
# user, then wait for user response
|
2200
2206
|
self.pending_message is not None
|
2201
2207
|
and self.pending_message.metadata.recipient == Entity.USER
|
2202
2208
|
and not self.agent.has_tool_message_attempt(self.pending_message)
|
@@ -2204,19 +2210,13 @@ class Task:
|
|
2204
2210
|
|
2205
2211
|
def _can_respond(self, e: Responder) -> bool:
|
2206
2212
|
user_can_respond = self._user_can_respond()
|
2207
|
-
|
2208
2213
|
if self.pending_sender == e or (e == Entity.USER and not user_can_respond):
|
2209
|
-
# sender is same as e (an entity cannot respond to its own msg),
|
2210
|
-
# or user cannot respond
|
2211
2214
|
return False
|
2212
|
-
|
2213
2215
|
if self.pending_message is None:
|
2214
2216
|
return True
|
2215
2217
|
if isinstance(e, Task) and not e.agent.can_respond(self.pending_message):
|
2216
2218
|
return False
|
2217
|
-
|
2218
2219
|
if self._recipient_mismatch(e):
|
2219
|
-
# Cannot respond if not addressed to this entity
|
2220
2220
|
return False
|
2221
2221
|
return self.pending_message.metadata.block != e
|
2222
2222
|
|
@@ -2230,7 +2230,6 @@ class Task:
|
|
2230
2230
|
Args:
|
2231
2231
|
enable (bool): value of `self.color_log` to set to,
|
2232
2232
|
which will enable/diable rich logging
|
2233
|
-
|
2234
2233
|
"""
|
2235
2234
|
self.color_log = enable
|
2236
2235
|
|
@@ -2247,16 +2246,13 @@ class Task:
|
|
2247
2246
|
Args:
|
2248
2247
|
msg (ChatDocument|str|None): message to parse
|
2249
2248
|
addressing_prefix (str): prefix to address other agents or entities,
|
2250
|
-
|
2249
|
+
(e.g. "@". See documentation of `TaskConfig` for details).
|
2251
2250
|
Returns:
|
2252
2251
|
Tuple[bool|None, str|None, str|None]:
|
2253
2252
|
bool: true=PASS, false=SEND, or None if neither
|
2254
2253
|
str: recipient, or None
|
2255
2254
|
str: content to send, or None
|
2256
2255
|
"""
|
2257
|
-
# handle routing instruction-strings in result if any,
|
2258
|
-
# such as PASS, PASS_TO, or SEND
|
2259
|
-
|
2260
2256
|
msg_str = msg.content if isinstance(msg, ChatDocument) else msg
|
2261
2257
|
if (
|
2262
2258
|
self.agent.has_tool_message_attempt(msg)
|
@@ -2264,10 +2260,7 @@ class Task:
|
|
2264
2260
|
and not msg_str.startswith(PASS_TO)
|
2265
2261
|
and not msg_str.startswith(SEND_TO)
|
2266
2262
|
):
|
2267
|
-
# if there's an attempted tool-call, we ignore any routing strings,
|
2268
|
-
# unless they are at the start of the msg
|
2269
2263
|
return None, None, None
|
2270
|
-
|
2271
2264
|
content = msg.content if isinstance(msg, ChatDocument) else msg
|
2272
2265
|
content = content.strip()
|
2273
2266
|
if PASS in content and PASS_TO not in content:
|
@@ -2279,10 +2272,7 @@ class Task:
|
|
2279
2272
|
and (addressee_content := parse_addressed_message(content, SEND_TO))[0]
|
2280
2273
|
is not None
|
2281
2274
|
):
|
2282
|
-
# Note this will discard any portion of content BEFORE SEND_TO.
|
2283
|
-
# TODO maybe make this configurable.
|
2284
2275
|
(addressee, content_to_send) = addressee_content
|
2285
|
-
# if no content then treat same as PASS_TO
|
2286
2276
|
if content_to_send == "":
|
2287
2277
|
return True, addressee, None
|
2288
2278
|
else:
|
@@ -2296,12 +2286,10 @@ class Task:
|
|
2296
2286
|
is not None
|
2297
2287
|
):
|
2298
2288
|
(addressee, content_to_send) = addressee_content
|
2299
|
-
# if no content then treat same as PASS_TO
|
2300
2289
|
if content_to_send == "":
|
2301
2290
|
return True, addressee, None
|
2302
2291
|
else:
|
2303
2292
|
return False, addressee, content_to_send
|
2304
|
-
|
2305
2293
|
return None, None, None
|
2306
2294
|
|
2307
2295
|
def _classify_event(
|
@@ -2310,19 +2298,13 @@ class Task:
|
|
2310
2298
|
"""Classify a message into an AgentEvent for sequence matching."""
|
2311
2299
|
if msg is None:
|
2312
2300
|
return AgentEvent(event_type=EventType.NO_RESPONSE)
|
2313
|
-
|
2314
|
-
# Determine the event type based on responder and message content
|
2315
2301
|
event_type = EventType.NO_RESPONSE
|
2316
2302
|
tool_name = None
|
2317
|
-
|
2318
|
-
# Check if there are tool messages
|
2319
2303
|
tool_messages = self.agent.try_get_tool_messages(msg, all_tools=True)
|
2320
2304
|
if tool_messages:
|
2321
2305
|
event_type = EventType.TOOL
|
2322
2306
|
if len(tool_messages) == 1:
|
2323
2307
|
tool_name = tool_messages[0].request
|
2324
|
-
|
2325
|
-
# Check responder type
|
2326
2308
|
if responder == Entity.LLM and not tool_messages:
|
2327
2309
|
event_type = EventType.LLM_RESPONSE
|
2328
2310
|
elif responder == Entity.AGENT:
|
@@ -2330,21 +2312,17 @@ class Task:
|
|
2330
2312
|
elif responder == Entity.USER:
|
2331
2313
|
event_type = EventType.USER_RESPONSE
|
2332
2314
|
elif isinstance(responder, Task):
|
2333
|
-
# For sub-task responses, check the sender in metadata
|
2334
2315
|
if msg.metadata.sender == Entity.LLM:
|
2335
2316
|
event_type = EventType.LLM_RESPONSE
|
2336
2317
|
elif msg.metadata.sender == Entity.AGENT:
|
2337
2318
|
event_type = EventType.AGENT_RESPONSE
|
2338
2319
|
else:
|
2339
2320
|
event_type = EventType.USER_RESPONSE
|
2340
|
-
|
2341
|
-
# Get sender name
|
2342
2321
|
sender_name = None
|
2343
2322
|
if isinstance(responder, Entity):
|
2344
2323
|
sender_name = responder.value
|
2345
2324
|
elif isinstance(responder, Task):
|
2346
2325
|
sender_name = responder.name
|
2347
|
-
|
2348
2326
|
return AgentEvent(
|
2349
2327
|
event_type=event_type,
|
2350
2328
|
tool_name=tool_name,
|
@@ -2356,71 +2334,74 @@ class Task:
|
|
2356
2334
|
) -> List[ChatDocument]:
|
2357
2335
|
"""Get the chain of messages from response sequence."""
|
2358
2336
|
if max_depth is None:
|
2359
|
-
# Get max depth needed from all sequences
|
2360
2337
|
max_depth = 50 # default fallback
|
2361
|
-
|
2362
|
-
|
2363
|
-
|
2364
|
-
# Simply return the last max_depth elements from response_sequence
|
2338
|
+
if self._parsed_done_sequences:
|
2339
|
+
max_depth = max(len(seq.events) for seq in self._parsed_done_sequences)
|
2365
2340
|
return self.response_sequence[-max_depth:]
|
2366
2341
|
|
2367
2342
|
def _matches_event(self, actual: AgentEvent, expected: AgentEvent) -> bool:
|
2368
2343
|
"""Check if an actual event matches an expected event pattern."""
|
2369
|
-
# Check event type
|
2370
2344
|
if expected.event_type == EventType.SPECIFIC_TOOL:
|
2371
2345
|
if actual.event_type != EventType.TOOL:
|
2372
2346
|
return False
|
2347
|
+
|
2348
|
+
# First try tool_class matching if available
|
2349
|
+
if expected.tool_class is not None:
|
2350
|
+
# Handle case where actual.tool_class might be a class instance
|
2351
|
+
if hasattr(actual, "tool_class") and actual.tool_class is not None:
|
2352
|
+
# If actual.tool_class is an instance, get its class
|
2353
|
+
if isinstance(actual.tool_class, type):
|
2354
|
+
actual_class = actual.tool_class
|
2355
|
+
else:
|
2356
|
+
actual_class = type(actual.tool_class)
|
2357
|
+
|
2358
|
+
# Compare the tool classes
|
2359
|
+
if actual_class == expected.tool_class:
|
2360
|
+
return True
|
2361
|
+
# Also check if actual tool is an instance of expected class
|
2362
|
+
if not isinstance(actual.tool_class, type) and isinstance(
|
2363
|
+
actual.tool_class, expected.tool_class
|
2364
|
+
):
|
2365
|
+
return True
|
2366
|
+
|
2367
|
+
# If tool_class comparison didn't match, continue to tool_name fallback
|
2368
|
+
|
2369
|
+
# Fall back to tool_name comparison for backwards compatibility
|
2373
2370
|
if expected.tool_name and actual.tool_name != expected.tool_name:
|
2374
2371
|
return False
|
2372
|
+
|
2375
2373
|
elif actual.event_type != expected.event_type:
|
2376
2374
|
return False
|
2377
|
-
|
2378
|
-
# Check sender if specified
|
2379
2375
|
if expected.sender and actual.sender != expected.sender:
|
2380
2376
|
return False
|
2381
|
-
|
2382
|
-
# TODO: Add content pattern matching for CONTENT_MATCH type
|
2383
|
-
|
2384
2377
|
return True
|
2385
2378
|
|
2386
2379
|
def _matches_sequence(
|
2387
2380
|
self, msg_chain: List[ChatDocument], sequence: DoneSequence
|
2388
2381
|
) -> bool:
|
2389
2382
|
"""Check if a message chain matches a done sequence.
|
2390
|
-
|
2391
2383
|
We traverse the message chain and try to match the sequence events.
|
2392
2384
|
The events don't have to be consecutive in the chain.
|
2393
2385
|
"""
|
2394
2386
|
if not sequence.events:
|
2395
2387
|
return False
|
2396
|
-
|
2397
|
-
# Convert messages to events
|
2398
2388
|
events = []
|
2399
2389
|
for i, msg in enumerate(msg_chain):
|
2400
|
-
# Determine responder from metadata or by checking previous message
|
2401
2390
|
responder = None
|
2402
2391
|
if msg.metadata.sender:
|
2403
2392
|
responder = msg.metadata.sender
|
2404
2393
|
elif msg.metadata.sender_name:
|
2405
|
-
# Could be a task name - keep as None for now since we can't resolve
|
2406
|
-
# the actual Task object from just the name
|
2407
2394
|
responder = None
|
2408
|
-
|
2409
2395
|
event = self._classify_event(msg, responder)
|
2410
2396
|
if event:
|
2411
2397
|
events.append(event)
|
2412
|
-
|
2413
|
-
# Try to match the sequence
|
2414
2398
|
seq_idx = 0
|
2415
2399
|
for event in events:
|
2416
2400
|
if seq_idx >= len(sequence.events):
|
2417
2401
|
break
|
2418
|
-
|
2419
2402
|
expected = sequence.events[seq_idx]
|
2420
2403
|
if self._matches_event(event, expected):
|
2421
2404
|
seq_idx += 1
|
2422
|
-
|
2423
|
-
# Check if we matched the entire sequence
|
2424
2405
|
return seq_idx == len(sequence.events)
|
2425
2406
|
|
2426
2407
|
def close_loggers(self) -> None:
|
@@ -2438,43 +2419,26 @@ class Task:
|
|
2438
2419
|
current_responder: Optional[Responder],
|
2439
2420
|
) -> bool:
|
2440
2421
|
"""Check if the message chain plus current message matches a done sequence.
|
2441
|
-
|
2442
2422
|
Process messages in reverse order (newest first) and match against
|
2443
2423
|
the sequence events in reverse order.
|
2444
2424
|
"""
|
2445
|
-
# Add current message to chain if not already there
|
2446
2425
|
if not msg_chain or msg_chain[-1].id() != current_msg.id():
|
2447
2426
|
msg_chain = msg_chain + [current_msg]
|
2448
|
-
|
2449
|
-
# If we don't have enough messages for the sequence, can't match
|
2450
2427
|
if len(msg_chain) < len(sequence.events):
|
2451
2428
|
return False
|
2452
|
-
|
2453
|
-
# Process in reverse order - start from the end of both lists
|
2454
2429
|
seq_idx = len(sequence.events) - 1
|
2455
2430
|
msg_idx = len(msg_chain) - 1
|
2456
|
-
|
2457
2431
|
while seq_idx >= 0 and msg_idx >= 0:
|
2458
2432
|
msg = msg_chain[msg_idx]
|
2459
2433
|
expected = sequence.events[seq_idx]
|
2460
|
-
|
2461
|
-
# Determine responder for this message
|
2462
2434
|
if msg_idx == len(msg_chain) - 1 and current_responder is not None:
|
2463
|
-
# For the last message, use the current responder
|
2464
2435
|
responder = current_responder
|
2465
2436
|
else:
|
2466
|
-
# For other messages, determine from metadata
|
2467
2437
|
responder = msg.metadata.sender
|
2468
|
-
|
2469
|
-
# Classify the event
|
2470
2438
|
event = self._classify_event(msg, responder)
|
2471
2439
|
if not event:
|
2472
2440
|
return False
|
2473
|
-
|
2474
|
-
# Check if it matches
|
2475
2441
|
matched = False
|
2476
|
-
|
2477
|
-
# Special handling for CONTENT_MATCH
|
2478
2442
|
if (
|
2479
2443
|
expected.event_type == EventType.CONTENT_MATCH
|
2480
2444
|
and expected.content_pattern
|
@@ -2483,14 +2447,9 @@ class Task:
|
|
2483
2447
|
matched = True
|
2484
2448
|
elif self._matches_event(event, expected):
|
2485
2449
|
matched = True
|
2486
|
-
|
2487
2450
|
if not matched:
|
2488
|
-
# Strict matching - no skipping allowed
|
2489
2451
|
return False
|
2490
2452
|
else:
|
2491
|
-
# Matched! Move to next expected event
|
2492
2453
|
seq_idx -= 1
|
2493
2454
|
msg_idx -= 1
|
2494
|
-
|
2495
|
-
# We matched if we've matched all events in the sequence
|
2496
2455
|
return seq_idx < 0
|
langroid/agent/tool_message.py
CHANGED
@@ -14,9 +14,9 @@ from random import choice
|
|
14
14
|
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar
|
15
15
|
|
16
16
|
from docstring_parser import parse
|
17
|
+
from pydantic import BaseModel, ConfigDict
|
17
18
|
|
18
19
|
from langroid.language_models.base import LLMFunctionSpec
|
19
|
-
from langroid.pydantic_v1 import BaseModel, Extra
|
20
20
|
from langroid.utils.pydantic_utils import (
|
21
21
|
_recursive_purge_dict_key,
|
22
22
|
generate_simple_schema,
|
@@ -40,6 +40,14 @@ def format_schema_for_strict(schema: Any) -> None:
|
|
40
40
|
This may not be equivalent to the original schema.
|
41
41
|
"""
|
42
42
|
if isinstance(schema, dict):
|
43
|
+
# Handle $ref nodes - they can't have any other properties
|
44
|
+
if "$ref" in schema:
|
45
|
+
# Keep only the $ref, remove all other properties like description
|
46
|
+
ref_value = schema["$ref"]
|
47
|
+
schema.clear()
|
48
|
+
schema["$ref"] = ref_value
|
49
|
+
return
|
50
|
+
|
43
51
|
if "type" in schema and schema["type"] == "object":
|
44
52
|
schema["additionalProperties"] = False
|
45
53
|
|
@@ -111,14 +119,21 @@ class ToolMessage(ABC, BaseModel):
|
|
111
119
|
# Optional param to limit number of tokens in the result of the tool.
|
112
120
|
_max_result_tokens: int | None = None
|
113
121
|
|
114
|
-
|
115
|
-
extra
|
116
|
-
arbitrary_types_allowed
|
117
|
-
|
118
|
-
validate_assignment
|
122
|
+
model_config = ConfigDict(
|
123
|
+
extra="allow",
|
124
|
+
arbitrary_types_allowed=False,
|
125
|
+
validate_default=True,
|
126
|
+
validate_assignment=True,
|
119
127
|
# do not include these fields in the generated schema
|
120
128
|
# since we don't require the LLM to specify them
|
121
|
-
|
129
|
+
json_schema_extra={"exclude": ["purpose", "id"]},
|
130
|
+
)
|
131
|
+
|
132
|
+
# Define excluded fields as a class method to avoid Pydantic treating it as
|
133
|
+
# a model field
|
134
|
+
@classmethod
|
135
|
+
def _get_excluded_fields(cls) -> set[str]:
|
136
|
+
return {"purpose", "id"}
|
122
137
|
|
123
138
|
@classmethod
|
124
139
|
def name(cls) -> str:
|
@@ -196,18 +211,18 @@ class ToolMessage(ABC, BaseModel):
|
|
196
211
|
return "\n\n".join(formatted_examples)
|
197
212
|
|
198
213
|
def to_json(self) -> str:
|
199
|
-
return self.
|
214
|
+
return self.model_dump_json(indent=4, exclude=self._get_excluded_fields())
|
200
215
|
|
201
216
|
def format_example(self) -> str:
|
202
|
-
return self.
|
217
|
+
return self.model_dump_json(indent=4, exclude=self._get_excluded_fields())
|
203
218
|
|
204
219
|
def dict_example(self) -> Dict[str, Any]:
|
205
|
-
return self.
|
220
|
+
return self.model_dump(exclude=self._get_excluded_fields())
|
206
221
|
|
207
222
|
def get_value_of_type(self, target_type: Type[Any]) -> Any:
|
208
223
|
"""Try to find a value of a desired type in the fields of the ToolMessage."""
|
209
|
-
ignore_fields = self.
|
210
|
-
for field_name in set(self.
|
224
|
+
ignore_fields = self._get_excluded_fields().union({"request"})
|
225
|
+
for field_name in set(self.model_dump().keys()) - ignore_fields:
|
211
226
|
value = getattr(self, field_name)
|
212
227
|
if is_instance_of(value, target_type):
|
213
228
|
return value
|
@@ -224,7 +239,7 @@ class ToolMessage(ABC, BaseModel):
|
|
224
239
|
Any: default value of the field, or None if not set or if the
|
225
240
|
field does not exist.
|
226
241
|
"""
|
227
|
-
schema = cls.
|
242
|
+
schema = cls.model_json_schema()
|
228
243
|
properties = schema["properties"]
|
229
244
|
return properties.get(f, {}).get("default", None)
|
230
245
|
|
@@ -304,7 +319,7 @@ class ToolMessage(ABC, BaseModel):
|
|
304
319
|
LLMFunctionSpec: the schema as an LLMFunctionSpec
|
305
320
|
|
306
321
|
"""
|
307
|
-
schema = copy.deepcopy(cls.
|
322
|
+
schema = copy.deepcopy(cls.model_json_schema())
|
308
323
|
docstring = parse(cls.__doc__ or "")
|
309
324
|
parameters = {
|
310
325
|
k: v for k, v in schema.items() if k not in ("title", "description")
|
@@ -316,7 +331,7 @@ class ToolMessage(ABC, BaseModel):
|
|
316
331
|
if "description" not in parameters["properties"][name]:
|
317
332
|
parameters["properties"][name]["description"] = description
|
318
333
|
|
319
|
-
excludes = cls.
|
334
|
+
excludes = cls._get_excluded_fields().copy()
|
320
335
|
if not request:
|
321
336
|
excludes = excludes.union({"request"})
|
322
337
|
# exclude 'excludes' from parameters["properties"]:
|
@@ -374,7 +389,8 @@ class ToolMessage(ABC, BaseModel):
|
|
374
389
|
_recursive_purge_dict_key(parameters, "additionalProperties")
|
375
390
|
return LLMFunctionSpec(
|
376
391
|
name=cls.default_value("request"),
|
377
|
-
description=cls.default_value("purpose")
|
392
|
+
description=cls.default_value("purpose")
|
393
|
+
or f"Tool for {cls.default_value('request')}",
|
378
394
|
parameters=parameters,
|
379
395
|
)
|
380
396
|
|
@@ -388,6 +404,6 @@ class ToolMessage(ABC, BaseModel):
|
|
388
404
|
"""
|
389
405
|
schema = generate_simple_schema(
|
390
406
|
cls,
|
391
|
-
exclude=list(cls.
|
407
|
+
exclude=list(cls._get_excluded_fields()),
|
392
408
|
)
|
393
409
|
return schema
|
@@ -4,10 +4,10 @@ from textwrap import dedent
|
|
4
4
|
from typing import Callable, List, Tuple, Type
|
5
5
|
|
6
6
|
import git
|
7
|
+
from pydantic import Field
|
7
8
|
|
8
9
|
from langroid.agent.tool_message import ToolMessage
|
9
10
|
from langroid.agent.xml_tool_message import XMLToolMessage
|
10
|
-
from langroid.pydantic_v1 import Field
|
11
11
|
from langroid.utils.git_utils import git_commit_file
|
12
12
|
from langroid.utils.system import create_file, list_dir, read_file
|
13
13
|
|
@@ -91,7 +91,9 @@ class WriteFileTool(XMLToolMessage):
|
|
91
91
|
content: str = Field(
|
92
92
|
...,
|
93
93
|
description="The content to write to the file",
|
94
|
-
|
94
|
+
json_schema_extra={
|
95
|
+
"verbatim": True
|
96
|
+
}, # preserve the content as is; uses CDATA section in XML
|
95
97
|
)
|
96
98
|
_curr_dir: Callable[[], str] | None = None
|
97
99
|
_git_repo: Callable[[], git.Repo] | None = None
|
@@ -27,12 +27,12 @@ from mcp.types import (
|
|
27
27
|
TextResourceContents,
|
28
28
|
Tool,
|
29
29
|
)
|
30
|
+
from pydantic import AnyUrl, BaseModel, Field, create_model
|
30
31
|
|
31
32
|
from langroid.agent.base import Agent
|
32
33
|
from langroid.agent.chat_document import ChatDocument
|
33
34
|
from langroid.agent.tool_message import ToolMessage
|
34
35
|
from langroid.parsing.file_attachment import FileAttachment
|
35
|
-
from langroid.pydantic_v1 import AnyUrl, BaseModel, Field, create_model
|
36
36
|
|
37
37
|
load_dotenv() # load environment variables from .env
|
38
38
|
|
@@ -253,11 +253,24 @@ class FastMCPClient:
|
|
253
253
|
from langroid.agent.tools.mcp.fastmcp_client import FastMCPClient
|
254
254
|
|
255
255
|
# pack up the payload
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
256
|
+
# Get exclude fields from model config with proper type checking
|
257
|
+
exclude_fields = set()
|
258
|
+
model_config = getattr(itself, "model_config", {})
|
259
|
+
if (
|
260
|
+
isinstance(model_config, dict)
|
261
|
+
and "json_schema_extra" in model_config
|
262
|
+
and model_config["json_schema_extra"] is not None
|
263
|
+
and isinstance(model_config["json_schema_extra"], dict)
|
264
|
+
and "exclude" in model_config["json_schema_extra"]
|
265
|
+
):
|
266
|
+
exclude_list = model_config["json_schema_extra"]["exclude"]
|
267
|
+
if isinstance(exclude_list, (list, set, tuple)):
|
268
|
+
exclude_fields = set(exclude_list)
|
269
|
+
|
270
|
+
# Add standard excluded fields
|
271
|
+
exclude_fields.update(["request", "purpose"])
|
272
|
+
|
273
|
+
payload = itself.model_dump(exclude=exclude_fields)
|
261
274
|
|
262
275
|
# restore any renamed fields
|
263
276
|
for orig, new in itself.__class__._renamed_fields.items(): # type: ignore
|
@@ -5,11 +5,12 @@ termination, routing to another agent, etc.
|
|
5
5
|
|
6
6
|
from typing import Any, List, Tuple
|
7
7
|
|
8
|
+
from pydantic import ConfigDict, field_validator
|
9
|
+
|
8
10
|
from langroid.agent.chat_agent import ChatAgent
|
9
11
|
from langroid.agent.chat_document import ChatDocument
|
10
12
|
from langroid.agent.tool_message import ToolMessage
|
11
13
|
from langroid.mytypes import Entity
|
12
|
-
from langroid.pydantic_v1 import Extra
|
13
14
|
from langroid.utils.types import to_string
|
14
15
|
|
15
16
|
|
@@ -48,6 +49,12 @@ class DoneTool(ToolMessage):
|
|
48
49
|
request: str = "done_tool"
|
49
50
|
content: str = ""
|
50
51
|
|
52
|
+
@field_validator("content", mode="before")
|
53
|
+
@classmethod
|
54
|
+
def convert_content_to_string(cls, v: Any) -> str:
|
55
|
+
"""Convert content to string if it's not already."""
|
56
|
+
return str(v) if v is not None else ""
|
57
|
+
|
51
58
|
def response(self, agent: ChatAgent) -> ChatDocument:
|
52
59
|
return agent.create_agent_response(
|
53
60
|
content=self.content,
|
@@ -90,14 +97,13 @@ class ResultTool(ToolMessage):
|
|
90
97
|
purpose: str = "Ignored; Wrapper for a structured message"
|
91
98
|
id: str = "" # placeholder for OpenAI-API tool_call_id
|
92
99
|
|
93
|
-
|
94
|
-
extra
|
95
|
-
arbitrary_types_allowed
|
96
|
-
|
97
|
-
validate_assignment
|
98
|
-
|
99
|
-
|
100
|
-
schema_extra = {"exclude": {"purpose", "id", "strict"}}
|
100
|
+
model_config = ConfigDict(
|
101
|
+
extra="allow",
|
102
|
+
arbitrary_types_allowed=False,
|
103
|
+
validate_default=True,
|
104
|
+
validate_assignment=True,
|
105
|
+
json_schema_extra={"exclude": ["purpose", "id", "strict"]},
|
106
|
+
)
|
101
107
|
|
102
108
|
def handle(self) -> AgentDoneTool:
|
103
109
|
return AgentDoneTool(tools=[self])
|
@@ -132,14 +138,13 @@ class FinalResultTool(ToolMessage):
|
|
132
138
|
id: str = "" # placeholder for OpenAI-API tool_call_id
|
133
139
|
_allow_llm_use: bool = False
|
134
140
|
|
135
|
-
|
136
|
-
extra
|
137
|
-
arbitrary_types_allowed
|
138
|
-
|
139
|
-
validate_assignment
|
140
|
-
|
141
|
-
|
142
|
-
schema_extra = {"exclude": {"purpose", "id", "strict"}}
|
141
|
+
model_config = ConfigDict(
|
142
|
+
extra="allow",
|
143
|
+
arbitrary_types_allowed=False,
|
144
|
+
validate_default=True,
|
145
|
+
validate_assignment=True,
|
146
|
+
json_schema_extra={"exclude": ["purpose", "id", "strict"]},
|
147
|
+
)
|
143
148
|
|
144
149
|
|
145
150
|
class PassTool(ToolMessage):
|