mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-17 21:25:46 +02:00
178 lines
5.1 KiB
Python
178 lines
5.1 KiB
Python
import ast
|
|
import re
|
|
from typing import (
|
|
Any,
|
|
Dict,
|
|
Iterator,
|
|
List,
|
|
Optional,
|
|
Sequence,
|
|
Tuple,
|
|
Union,
|
|
)
|
|
|
|
from langchain_core.exceptions import OutputParserException
|
|
from langchain_core.messages import BaseMessage
|
|
from langchain_core.output_parsers.transform import BaseTransformOutputParser
|
|
from langchain_core.runnables import RunnableConfig
|
|
from langchain_core.tools import BaseTool
|
|
from typing_extensions import TypedDict
|
|
|
|
THOUGHT_PATTERN = r"Thought: ([^\n]*)"
|
|
ACTION_PATTERN = r"\n*(\d+)\. (\w+)\((.*)\)(\s*#\w+\n)?"
|
|
# $1 or ${1} -> 1
|
|
ID_PATTERN = r"\$\{?(\d+)\}?"
|
|
END_OF_PLAN = "<END_OF_PLAN>"
|
|
|
|
|
|
### Helper functions
|
|
|
|
|
|
def _ast_parse(arg: str) -> Any:
|
|
try:
|
|
return ast.literal_eval(arg)
|
|
except: # noqa
|
|
return arg
|
|
|
|
|
|
def _parse_llm_compiler_action_args(args: str, tool: Union[str, BaseTool]) -> list[Any]:
|
|
"""Parse arguments from a string."""
|
|
if args == "":
|
|
return ()
|
|
if isinstance(tool, str):
|
|
return ()
|
|
extracted_args = {}
|
|
tool_key = None
|
|
prev_idx = None
|
|
for key in tool.args.keys():
|
|
# Split if present
|
|
if f"{key}=" in args:
|
|
idx = args.index(f"{key}=")
|
|
if prev_idx is not None:
|
|
extracted_args[tool_key] = _ast_parse(
|
|
args[prev_idx:idx].strip().rstrip(",")
|
|
)
|
|
args = args.split(f"{key}=", 1)[1]
|
|
tool_key = key
|
|
prev_idx = 0
|
|
if prev_idx is not None:
|
|
extracted_args[tool_key] = _ast_parse(
|
|
args[prev_idx:].strip().rstrip(",").rstrip(")")
|
|
)
|
|
return extracted_args
|
|
|
|
|
|
def default_dependency_rule(idx, args: str):
|
|
matches = re.findall(ID_PATTERN, args)
|
|
numbers = [int(match) for match in matches]
|
|
return idx in numbers
|
|
|
|
|
|
def _get_dependencies_from_graph(
|
|
idx: int, tool_name: str, args: Dict[str, Any]
|
|
) -> dict[str, list[str]]:
|
|
"""Get dependencies from a graph."""
|
|
if tool_name == "join":
|
|
return list(range(1, idx))
|
|
return [i for i in range(1, idx) if default_dependency_rule(i, str(args))]
|
|
|
|
|
|
class Task(TypedDict):
|
|
idx: int
|
|
tool: BaseTool
|
|
args: list
|
|
dependencies: Dict[str, list]
|
|
thought: Optional[str]
|
|
|
|
|
|
def instantiate_task(
|
|
tools: Sequence[BaseTool],
|
|
idx: int,
|
|
tool_name: str,
|
|
args: Union[str, Any],
|
|
thought: Optional[str] = None,
|
|
) -> Task:
|
|
if tool_name == "join":
|
|
tool = "join"
|
|
else:
|
|
try:
|
|
tool = tools[[tool.name for tool in tools].index(tool_name)]
|
|
except ValueError as e:
|
|
raise OutputParserException(f"Tool {tool_name} not found.") from e
|
|
tool_args = _parse_llm_compiler_action_args(args, tool)
|
|
dependencies = _get_dependencies_from_graph(idx, tool_name, tool_args)
|
|
|
|
return Task(
|
|
idx=idx,
|
|
tool=tool,
|
|
args=tool_args,
|
|
dependencies=dependencies,
|
|
thought=thought,
|
|
)
|
|
|
|
|
|
class LLMCompilerPlanParser(BaseTransformOutputParser[dict], extra="allow"):
|
|
"""Planning output parser."""
|
|
|
|
tools: List[BaseTool]
|
|
|
|
def _transform(self, input: Iterator[Union[str, BaseMessage]]) -> Iterator[Task]:
|
|
texts = []
|
|
# TODO: Cleanup tuple state tracking here.
|
|
thought = None
|
|
for chunk in input:
|
|
# Assume input is str. TODO: support vision/other formats
|
|
text = chunk if isinstance(chunk, str) else str(chunk.content)
|
|
for task, thought in self.ingest_token(text, texts, thought):
|
|
yield task
|
|
# Final possible task
|
|
if texts:
|
|
task, _ = self._parse_task("".join(texts), thought)
|
|
if task:
|
|
yield task
|
|
|
|
def parse(self, text: str) -> List[Task]:
|
|
return list(self._transform([text]))
|
|
|
|
def stream(
|
|
self,
|
|
input: str | BaseMessage,
|
|
config: RunnableConfig | None = None,
|
|
**kwargs: Any | None,
|
|
) -> Iterator[Task]:
|
|
yield from self.transform([input], config, **kwargs)
|
|
|
|
def ingest_token(
|
|
self, token: str, buffer: List[str], thought: Optional[str]
|
|
) -> Iterator[Tuple[Optional[Task], str]]:
|
|
buffer.append(token)
|
|
if "\n" in token:
|
|
buffer_ = "".join(buffer).split("\n")
|
|
suffix = buffer_[-1]
|
|
for line in buffer_[:-1]:
|
|
task, thought = self._parse_task(line, thought)
|
|
if task:
|
|
yield task, thought
|
|
buffer.clear()
|
|
buffer.append(suffix)
|
|
|
|
def _parse_task(self, line: str, thought: Optional[str] = None):
|
|
task = None
|
|
if match := re.match(THOUGHT_PATTERN, line):
|
|
# Optionally, action can be preceded by a thought
|
|
thought = match.group(1)
|
|
elif match := re.match(ACTION_PATTERN, line):
|
|
# if action is parsed, return the task, and clear the buffer
|
|
idx, tool_name, args, _ = match.groups()
|
|
idx = int(idx)
|
|
task = instantiate_task(
|
|
tools=self.tools,
|
|
idx=idx,
|
|
tool_name=tool_name,
|
|
args=args,
|
|
thought=thought,
|
|
)
|
|
thought = None
|
|
# Else it is just dropped
|
|
return task, thought
|