langroid 0.54.2__py3-none-any.whl → 0.55.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.
@@ -0,0 +1,151 @@
1
+ """Parser for done sequence DSL (Domain Specific Language).
2
+
3
+ Converts string patterns into DoneSequence objects for convenient task completion
4
+ configuration.
5
+
6
+ Examples:
7
+ "T, A" -> Tool followed by Agent response
8
+ "T[calculator], A" -> Specific tool 'calculator' followed by Agent response
9
+ "L, T, A, L" -> LLM, Tool, Agent, LLM sequence
10
+ "C[quit|exit]" -> Content matching regex pattern
11
+ """
12
+
13
+ import re
14
+ from typing import List, Union
15
+
16
+ from langroid.pydantic_v1 import BaseModel
17
+
18
+ from .task import AgentEvent, DoneSequence, EventType
19
+
20
+
21
+ def parse_done_sequence(sequence: Union[str, DoneSequence]) -> DoneSequence:
22
+ """Parse a string pattern or return existing DoneSequence unchanged.
23
+
24
+ Args:
25
+ sequence: Either a DoneSequence object or a string pattern to parse
26
+
27
+ Returns:
28
+ DoneSequence object
29
+
30
+ Raises:
31
+ ValueError: If the string pattern is invalid
32
+ """
33
+ if isinstance(sequence, DoneSequence):
34
+ return sequence
35
+
36
+ if not isinstance(sequence, str):
37
+ raise ValueError(f"Expected string or DoneSequence, got {type(sequence)}")
38
+
39
+ events = _parse_string_pattern(sequence)
40
+ return DoneSequence(events=events)
41
+
42
+
43
+ def _parse_string_pattern(pattern: str) -> List[AgentEvent]:
44
+ """Parse a string pattern into a list of AgentEvent objects.
45
+
46
+ Pattern format:
47
+ - Single letter codes: T, A, L, U, N, C
48
+ - Specific tools: T[tool_name]
49
+ - Content match: C[regex_pattern]
50
+ - Separated by commas, spaces allowed
51
+
52
+ Args:
53
+ pattern: String pattern to parse
54
+
55
+ Returns:
56
+ List of AgentEvent objects
57
+
58
+ Raises:
59
+ ValueError: If pattern is invalid
60
+ """
61
+ events = []
62
+
63
+ # Split by comma and strip whitespace
64
+ parts = [p.strip() for p in pattern.split(",")]
65
+
66
+ for part in parts:
67
+ if not part:
68
+ continue
69
+
70
+ event = _parse_event_token(part)
71
+ events.append(event)
72
+
73
+ if not events:
74
+ raise ValueError(f"No valid events found in pattern: {pattern}")
75
+
76
+ return events
77
+
78
+
79
+ def _parse_event_token(token: str) -> AgentEvent:
80
+ """Parse a single event token into an AgentEvent.
81
+
82
+ Args:
83
+ token: Single event token (e.g., "T", "T[calc]", "C[quit|exit]")
84
+
85
+ Returns:
86
+ AgentEvent object
87
+
88
+ Raises:
89
+ ValueError: If token is invalid
90
+ """
91
+ # Check for bracket notation
92
+ bracket_match = re.match(r"^([A-Z])\[([^\]]+)\]$", token)
93
+
94
+ if bracket_match:
95
+ event_code = bracket_match.group(1)
96
+ param = bracket_match.group(2)
97
+
98
+ if event_code == "T":
99
+ # Specific tool: T[tool_name]
100
+ return AgentEvent(event_type=EventType.SPECIFIC_TOOL, tool_name=param)
101
+ elif event_code == "C":
102
+ # Content match: C[regex_pattern]
103
+ return AgentEvent(event_type=EventType.CONTENT_MATCH, content_pattern=param)
104
+ else:
105
+ raise ValueError(
106
+ f"Invalid event code with brackets: {event_code}. "
107
+ "Only T[tool] and C[pattern] are supported."
108
+ )
109
+
110
+ # Simple single-letter codes
111
+ event_map = {
112
+ "T": EventType.TOOL,
113
+ "A": EventType.AGENT_RESPONSE,
114
+ "L": EventType.LLM_RESPONSE,
115
+ "U": EventType.USER_RESPONSE,
116
+ "N": EventType.NO_RESPONSE,
117
+ "C": EventType.CONTENT_MATCH, # C without brackets matches any content
118
+ }
119
+
120
+ if token in event_map:
121
+ return AgentEvent(event_type=event_map[token])
122
+
123
+ # If not a single letter, could be a full event type name
124
+ token_upper = token.upper()
125
+ if token_upper == "TOOL":
126
+ return AgentEvent(event_type=EventType.TOOL)
127
+ elif token_upper == "AGENT":
128
+ return AgentEvent(event_type=EventType.AGENT_RESPONSE)
129
+ elif token_upper == "LLM":
130
+ return AgentEvent(event_type=EventType.LLM_RESPONSE)
131
+ elif token_upper == "USER":
132
+ return AgentEvent(event_type=EventType.USER_RESPONSE)
133
+ else:
134
+ raise ValueError(
135
+ f"Invalid event token: '{token}'. "
136
+ "Valid tokens are: T, A, L, U, N, C, or T[tool_name], C[pattern]"
137
+ )
138
+
139
+
140
+ def parse_done_sequences(
141
+ sequences: List[Union[str, DoneSequence]]
142
+ ) -> List[DoneSequence]:
143
+ """Parse a list of mixed string patterns and DoneSequence objects.
144
+
145
+ Args:
146
+ sequences: List containing strings and/or DoneSequence objects
147
+
148
+ Returns:
149
+ List of DoneSequence objects
150
+ """
151
+ return [parse_done_sequence(seq) for seq in sequences]
langroid/agent/task.py CHANGED
@@ -6,6 +6,7 @@ import logging
6
6
  import re
7
7
  import threading
8
8
  from collections import Counter, OrderedDict, deque
9
+ from enum import Enum
9
10
  from pathlib import Path
10
11
  from types import SimpleNamespace
11
12
  from typing import (
@@ -20,6 +21,7 @@ from typing import (
20
21
  Tuple,
21
22
  Type,
22
23
  TypeVar,
24
+ Union,
23
25
  cast,
24
26
  overload,
25
27
  )
@@ -69,6 +71,38 @@ def noop_fn(*args: List[Any], **kwargs: Dict[str, Any]) -> None:
69
71
  pass
70
72
 
71
73
 
74
+ class EventType(str, Enum):
75
+ """Types of events that can occur in a task"""
76
+
77
+ TOOL = "tool" # Any tool generated
78
+ SPECIFIC_TOOL = "specific_tool" # Specific tool by name
79
+ LLM_RESPONSE = "llm_response" # LLM generates response
80
+ AGENT_RESPONSE = "agent_response" # Agent responds
81
+ USER_RESPONSE = "user_response" # User responds
82
+ CONTENT_MATCH = "content_match" # Response matches pattern
83
+ NO_RESPONSE = "no_response" # No valid response from entity
84
+ CUSTOM = "custom" # Custom condition
85
+
86
+
87
+ class AgentEvent(BaseModel):
88
+ """Single event in a task sequence"""
89
+
90
+ event_type: EventType
91
+ tool_name: Optional[str] = None # For SPECIFIC_TOOL
92
+ content_pattern: Optional[str] = None # For CONTENT_MATCH (regex)
93
+ responder: Optional[str] = None # Specific responder name
94
+ # Optionally match only if the responder was specific entity/task
95
+ sender: Optional[str] = None # Entity name or Task name that sent the message
96
+
97
+
98
+ class DoneSequence(BaseModel):
99
+ """A sequence of events that triggers task completion"""
100
+
101
+ events: List[AgentEvent]
102
+ # Optional name for debugging
103
+ name: Optional[str] = None
104
+
105
+
72
106
  class TaskConfig(BaseModel):
73
107
  """Configuration for a Task. This is a container for any params that
74
108
  we didn't include in the task `__init__` method.
@@ -108,6 +142,9 @@ class TaskConfig(BaseModel):
108
142
  contains a Tool attempt by the LLM
109
143
  (including tools not handled by the agent).
110
144
  Default is False.
145
+ done_sequences (List[DoneSequence]): List of event sequences that trigger task
146
+ completion. Task is done if ANY sequence matches the recent event history.
147
+ Each sequence is checked against the message parent chain.
111
148
 
112
149
  """
113
150
 
@@ -120,6 +157,7 @@ class TaskConfig(BaseModel):
120
157
  allow_subtask_multi_oai_tools: bool = True
121
158
  recognize_string_signals: bool = True
122
159
  done_if_tool: bool = False
160
+ done_sequences: Optional[List[Union[str, DoneSequence]]] = None
123
161
 
124
162
 
125
163
  class Task:
@@ -257,6 +295,14 @@ class Task:
257
295
  set_parent_agent=noop_fn,
258
296
  )
259
297
  self.config = config
298
+ # Store parsed done sequences
299
+ self._parsed_done_sequences: Optional[List[DoneSequence]] = None
300
+ if self.config.done_sequences:
301
+ from .done_sequence_parser import parse_done_sequences
302
+
303
+ self._parsed_done_sequences = parse_done_sequences(
304
+ self.config.done_sequences
305
+ )
260
306
  # how to behave as a sub-task; can be overridden by `add_sub_task()`
261
307
  self.config_sub_task = copy.deepcopy(config)
262
308
  # counts of distinct pending messages in history,
@@ -360,6 +406,8 @@ class Task:
360
406
  self.single_round = single_round
361
407
  self.turns = -1 # no limit
362
408
  self.llm_delegate = llm_delegate
409
+ # Track last responder for done sequence checking
410
+ self._last_responder: Optional[Responder] = None
363
411
  if llm_delegate:
364
412
  if self.single_round:
365
413
  # 0: User instructs (delegating to LLM);
@@ -1276,6 +1324,9 @@ class Task:
1276
1324
 
1277
1325
  self._update_no_answer_vars(result)
1278
1326
 
1327
+ # Store the last responder for done sequence checking
1328
+ self._last_responder = r
1329
+
1279
1330
  # pending_sender is of type Responder,
1280
1331
  # i.e. it is either one of the agent's entities
1281
1332
  # OR a sub-task, that has produced a valid response.
@@ -1841,6 +1892,23 @@ class Task:
1841
1892
  ):
1842
1893
  return (True, StatusCode.DONE)
1843
1894
 
1895
+ # Check done sequences
1896
+ if self._parsed_done_sequences and result is not None:
1897
+ # Get the message chain from the current result
1898
+ msg_chain = self._get_message_chain(result)
1899
+
1900
+ # Use last responder if r not provided
1901
+ responder = r if r is not None else self._last_responder
1902
+
1903
+ # Check each sequence
1904
+ for sequence in self._parsed_done_sequences:
1905
+ if self._matches_sequence_with_current(
1906
+ msg_chain, sequence, result, responder
1907
+ ):
1908
+ seq_name = sequence.name or "unnamed"
1909
+ logger.info(f"Task {self.name} done: matched sequence '{seq_name}'")
1910
+ return (True, StatusCode.DONE)
1911
+
1844
1912
  allow_done_string = self.config.recognize_string_signals
1845
1913
  # An entity decided task is done, either via DoneTool,
1846
1914
  # or by explicitly saying DONE
@@ -2127,3 +2195,196 @@ class Task:
2127
2195
  return False, addressee, content_to_send
2128
2196
 
2129
2197
  return None, None, None
2198
+
2199
+ def _classify_event(
2200
+ self, msg: ChatDocument | None, responder: Responder | None
2201
+ ) -> Optional[AgentEvent]:
2202
+ """Classify a message into an AgentEvent for sequence matching."""
2203
+ if msg is None:
2204
+ return AgentEvent(event_type=EventType.NO_RESPONSE)
2205
+
2206
+ # Determine the event type based on responder and message content
2207
+ event_type = EventType.NO_RESPONSE
2208
+ tool_name = None
2209
+
2210
+ # Check if there are tool messages
2211
+ tool_messages = self.agent.try_get_tool_messages(msg, all_tools=True)
2212
+ if tool_messages:
2213
+ event_type = EventType.TOOL
2214
+ if len(tool_messages) == 1:
2215
+ tool_name = tool_messages[0].request
2216
+
2217
+ # Check responder type
2218
+ if responder == Entity.LLM and not tool_messages:
2219
+ event_type = EventType.LLM_RESPONSE
2220
+ elif responder == Entity.AGENT:
2221
+ event_type = EventType.AGENT_RESPONSE
2222
+ elif responder == Entity.USER:
2223
+ event_type = EventType.USER_RESPONSE
2224
+ elif isinstance(responder, Task):
2225
+ # For sub-task responses, check the sender in metadata
2226
+ if msg.metadata.sender == Entity.LLM:
2227
+ event_type = EventType.LLM_RESPONSE
2228
+ elif msg.metadata.sender == Entity.AGENT:
2229
+ event_type = EventType.AGENT_RESPONSE
2230
+ else:
2231
+ event_type = EventType.USER_RESPONSE
2232
+
2233
+ # Get sender name
2234
+ sender_name = None
2235
+ if isinstance(responder, Entity):
2236
+ sender_name = responder.value
2237
+ elif isinstance(responder, Task):
2238
+ sender_name = responder.name
2239
+
2240
+ return AgentEvent(
2241
+ event_type=event_type,
2242
+ tool_name=tool_name,
2243
+ sender=sender_name,
2244
+ )
2245
+
2246
+ def _get_message_chain(
2247
+ self, msg: ChatDocument | None, max_depth: Optional[int] = None
2248
+ ) -> List[ChatDocument]:
2249
+ """Get the chain of messages by following parent pointers."""
2250
+ if max_depth is None:
2251
+ # Get max depth needed from all sequences
2252
+ max_depth = 50 # default fallback
2253
+ if self._parsed_done_sequences:
2254
+ max_depth = max(len(seq.events) for seq in self._parsed_done_sequences)
2255
+
2256
+ chain = []
2257
+ current = msg
2258
+ depth = 0
2259
+
2260
+ while current is not None and depth < max_depth:
2261
+ chain.append(current)
2262
+ current = current.parent
2263
+ depth += 1
2264
+
2265
+ # Reverse to get chronological order (oldest first)
2266
+ return list(reversed(chain))
2267
+
2268
+ def _matches_event(self, actual: AgentEvent, expected: AgentEvent) -> bool:
2269
+ """Check if an actual event matches an expected event pattern."""
2270
+ # Check event type
2271
+ if expected.event_type == EventType.SPECIFIC_TOOL:
2272
+ if actual.event_type != EventType.TOOL:
2273
+ return False
2274
+ if expected.tool_name and actual.tool_name != expected.tool_name:
2275
+ return False
2276
+ elif actual.event_type != expected.event_type:
2277
+ return False
2278
+
2279
+ # Check sender if specified
2280
+ if expected.sender and actual.sender != expected.sender:
2281
+ return False
2282
+
2283
+ # TODO: Add content pattern matching for CONTENT_MATCH type
2284
+
2285
+ return True
2286
+
2287
+ def _matches_sequence(
2288
+ self, msg_chain: List[ChatDocument], sequence: DoneSequence
2289
+ ) -> bool:
2290
+ """Check if a message chain matches a done sequence.
2291
+
2292
+ We traverse the message chain and try to match the sequence events.
2293
+ The events don't have to be consecutive in the chain.
2294
+ """
2295
+ if not sequence.events:
2296
+ return False
2297
+
2298
+ # Convert messages to events
2299
+ events = []
2300
+ for i, msg in enumerate(msg_chain):
2301
+ # Determine responder from metadata or by checking previous message
2302
+ responder = None
2303
+ if msg.metadata.sender:
2304
+ responder = msg.metadata.sender
2305
+ elif msg.metadata.sender_name:
2306
+ # Could be a task name - keep as None for now since we can't resolve
2307
+ # the actual Task object from just the name
2308
+ responder = None
2309
+
2310
+ event = self._classify_event(msg, responder)
2311
+ if event:
2312
+ events.append(event)
2313
+
2314
+ # Try to match the sequence
2315
+ seq_idx = 0
2316
+ for event in events:
2317
+ if seq_idx >= len(sequence.events):
2318
+ break
2319
+
2320
+ expected = sequence.events[seq_idx]
2321
+ if self._matches_event(event, expected):
2322
+ seq_idx += 1
2323
+
2324
+ # Check if we matched the entire sequence
2325
+ return seq_idx == len(sequence.events)
2326
+
2327
+ def _matches_sequence_with_current(
2328
+ self,
2329
+ msg_chain: List[ChatDocument],
2330
+ sequence: DoneSequence,
2331
+ current_msg: ChatDocument,
2332
+ current_responder: Optional[Responder],
2333
+ ) -> bool:
2334
+ """Check if the message chain plus current message matches a done sequence.
2335
+
2336
+ Process messages in reverse order (newest first) and match against
2337
+ the sequence events in reverse order.
2338
+ """
2339
+ # Add current message to chain if not already there
2340
+ if not msg_chain or msg_chain[-1].id() != current_msg.id():
2341
+ msg_chain = msg_chain + [current_msg]
2342
+
2343
+ # If we don't have enough messages for the sequence, can't match
2344
+ if len(msg_chain) < len(sequence.events):
2345
+ return False
2346
+
2347
+ # Process in reverse order - start from the end of both lists
2348
+ seq_idx = len(sequence.events) - 1
2349
+ msg_idx = len(msg_chain) - 1
2350
+
2351
+ while seq_idx >= 0 and msg_idx >= 0:
2352
+ msg = msg_chain[msg_idx]
2353
+ expected = sequence.events[seq_idx]
2354
+
2355
+ # Determine responder for this message
2356
+ if msg_idx == len(msg_chain) - 1 and current_responder is not None:
2357
+ # For the last message, use the current responder
2358
+ responder = current_responder
2359
+ else:
2360
+ # For other messages, determine from metadata
2361
+ responder = msg.metadata.sender
2362
+
2363
+ # Classify the event
2364
+ event = self._classify_event(msg, responder)
2365
+ if not event:
2366
+ return False
2367
+
2368
+ # Check if it matches
2369
+ matched = False
2370
+
2371
+ # Special handling for CONTENT_MATCH
2372
+ if (
2373
+ expected.event_type == EventType.CONTENT_MATCH
2374
+ and expected.content_pattern
2375
+ ):
2376
+ if re.search(expected.content_pattern, msg.content, re.IGNORECASE):
2377
+ matched = True
2378
+ elif self._matches_event(event, expected):
2379
+ matched = True
2380
+
2381
+ if not matched:
2382
+ # Strict matching - no skipping allowed
2383
+ return False
2384
+ else:
2385
+ # Matched! Move to next expected event
2386
+ seq_idx -= 1
2387
+ msg_idx -= 1
2388
+
2389
+ # We matched if we've matched all events in the sequence
2390
+ return seq_idx < 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langroid
3
- Version: 0.54.2
3
+ Version: 0.55.0
4
4
  Summary: Harness LLMs with Multi-Agent Programming
5
5
  Author-email: Prasad Chalasani <pchalasani@gmail.com>
6
6
  License: MIT
@@ -209,7 +209,7 @@ Description-Content-Type: text/markdown
209
209
  [![PyPI - Version](https://img.shields.io/pypi/v/langroid)](https://pypi.org/project/langroid/)
210
210
  [![Downloads](https://img.shields.io/pypi/dm/langroid)](https://pypi.org/project/langroid/)
211
211
  [![Pytest](https://github.com/langroid/langroid/actions/workflows/pytest.yml/badge.svg)](https://github.com/langroid/langroid/actions/workflows/pytest.yml)
212
- [![codecov](https://codecov.io/gh/langroid/langroid/branch/main/graph/badge.svg?token=H94BX5F0TE)](https://codecov.io/gh/langroid/langroid)
212
+ [![codecov](https://codecov.io/gh/langroid/langroid/graph/badge.svg)](https://codecov.io/gh/langroid/langroid)
213
213
  [![Multi-Architecture DockerHub](https://github.com/langroid/langroid/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/langroid/langroid/actions/workflows/docker-publish.yml)
214
214
 
215
215
  [![Static Badge](https://img.shields.io/badge/Documentation-blue?link=https%3A%2F%2Flangroid.github.io%2Flangroid%2F&link=https%3A%2F%2Flangroid.github.io%2Flangroid%2F)](https://langroid.github.io/langroid)
@@ -7,8 +7,9 @@ langroid/agent/base.py,sha256=a45iqoWet2W60h4wUIOyHDYlotgS0asqIjfbONT4fZQ,85706
7
7
  langroid/agent/batch.py,sha256=wpE9RqCNDVDhAXkCB7wEqfCIEAi6qKcrhaZ-Zr9T4C0,21375
8
8
  langroid/agent/chat_agent.py,sha256=2HIYzYxkrGkRIS97ioKfIqjaW3RbX89M39LjzBobBEY,88381
9
9
  langroid/agent/chat_document.py,sha256=6O20Fp4QrquykaF2jFtwNHkvcoDte1LLwVZNk9mVH9c,18057
10
+ langroid/agent/done_sequence_parser.py,sha256=GiLTVtzzKb_YQcPpwqOajKu97RSUzZC6ae_ZagDmt_A,4425
10
11
  langroid/agent/openai_assistant.py,sha256=JkAcs02bIrgPNVvUWVR06VCthc5-ulla2QMBzux_q6o,34340
11
- langroid/agent/task.py,sha256=Ns9SoeMoAHDW7OEcPHJ22cgQKsHzWSt9UAWi9BFl4NM,91366
12
+ langroid/agent/task.py,sha256=1xjey94vQRDDmXnhYCVu5D6Nw-3dVUaf_bUmXzRKxIk,101213
12
13
  langroid/agent/tool_message.py,sha256=BhjP-_TfQ2tgxuY4Yo_JHLOwwt0mJ4BwjPnREvEY4vk,14744
13
14
  langroid/agent/xml_tool_message.py,sha256=oeBKnJNoGaKdtz39XoWGMTNlVyXew2MWH5lgtYeh8wQ,15496
14
15
  langroid/agent/callbacks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -133,7 +134,7 @@ langroid/vector_store/pineconedb.py,sha256=otxXZNaBKb9f_H75HTaU3lMHiaR2NUp5MqwLZ
133
134
  langroid/vector_store/postgres.py,sha256=wHPtIi2qM4fhO4pMQr95pz1ZCe7dTb2hxl4VYspGZoA,16104
134
135
  langroid/vector_store/qdrantdb.py,sha256=O6dSBoDZ0jzfeVBd7LLvsXu083xs2fxXtPa9gGX3JX4,18443
135
136
  langroid/vector_store/weaviatedb.py,sha256=Yn8pg139gOy3zkaPfoTbMXEEBCiLiYa1MU5d_3UA1K4,11847
136
- langroid-0.54.2.dist-info/METADATA,sha256=MyoCOXBAWkcArSbJp4sYz9ORHKlYwpcyo2zOAUxe5TQ,65395
137
- langroid-0.54.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
138
- langroid-0.54.2.dist-info/licenses/LICENSE,sha256=EgVbvA6VSYgUlvC3RvPKehSg7MFaxWDsFuzLOsPPfJg,1065
139
- langroid-0.54.2.dist-info/RECORD,,
137
+ langroid-0.55.0.dist-info/METADATA,sha256=5RQdMjfiolUraCednhoHvcQVOaSHcOqxHj5e-JS3M6Q,65366
138
+ langroid-0.55.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
139
+ langroid-0.55.0.dist-info/licenses/LICENSE,sha256=EgVbvA6VSYgUlvC3RvPKehSg7MFaxWDsFuzLOsPPfJg,1065
140
+ langroid-0.55.0.dist-info/RECORD,,