lucid-graphql 0.2.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.
- lucid/__init__.py +67 -0
- lucid/agent.py +302 -0
- lucid/cli.py +58 -0
- lucid/client.py +287 -0
- lucid/errors.py +31 -0
- lucid/models.py +47 -0
- lucid/py.typed +0 -0
- lucid/schema.py +172 -0
- lucid/tools.py +173 -0
- lucid/transport.py +75 -0
- lucid_graphql-0.2.0.dist-info/METADATA +315 -0
- lucid_graphql-0.2.0.dist-info/RECORD +14 -0
- lucid_graphql-0.2.0.dist-info/WHEEL +4 -0
- lucid_graphql-0.2.0.dist-info/entry_points.txt +4 -0
lucid/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""lucid — natural language to GraphQL, driven by an agentic workflow."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _version
|
|
5
|
+
|
|
6
|
+
from lucid.client import (
|
|
7
|
+
EventStream as EventStream,
|
|
8
|
+
)
|
|
9
|
+
from lucid.client import (
|
|
10
|
+
LucidClient as LucidClient,
|
|
11
|
+
)
|
|
12
|
+
from lucid.client import (
|
|
13
|
+
QueryStream as QueryStream,
|
|
14
|
+
)
|
|
15
|
+
from lucid.client import (
|
|
16
|
+
ask as ask,
|
|
17
|
+
)
|
|
18
|
+
from lucid.client import (
|
|
19
|
+
ask_stream as ask_stream,
|
|
20
|
+
)
|
|
21
|
+
from lucid.client import (
|
|
22
|
+
create as create,
|
|
23
|
+
)
|
|
24
|
+
from lucid.client import (
|
|
25
|
+
generate as generate,
|
|
26
|
+
)
|
|
27
|
+
from lucid.client import (
|
|
28
|
+
generate_stream as generate_stream,
|
|
29
|
+
)
|
|
30
|
+
from lucid.errors import (
|
|
31
|
+
LucidError as LucidError,
|
|
32
|
+
)
|
|
33
|
+
from lucid.errors import (
|
|
34
|
+
QueryExecutionError as QueryExecutionError,
|
|
35
|
+
)
|
|
36
|
+
from lucid.errors import (
|
|
37
|
+
QueryValidationError as QueryValidationError,
|
|
38
|
+
)
|
|
39
|
+
from lucid.errors import (
|
|
40
|
+
SchemaError as SchemaError,
|
|
41
|
+
)
|
|
42
|
+
from lucid.errors import (
|
|
43
|
+
TransportError as TransportError,
|
|
44
|
+
)
|
|
45
|
+
from lucid.models import (
|
|
46
|
+
Event as Event,
|
|
47
|
+
)
|
|
48
|
+
from lucid.models import (
|
|
49
|
+
Response as Response,
|
|
50
|
+
)
|
|
51
|
+
from lucid.models import (
|
|
52
|
+
RunMetrics as RunMetrics,
|
|
53
|
+
)
|
|
54
|
+
from lucid.transport import (
|
|
55
|
+
ExecutionResult as ExecutionResult,
|
|
56
|
+
)
|
|
57
|
+
from lucid.transport import (
|
|
58
|
+
HttpxTransport as HttpxTransport,
|
|
59
|
+
)
|
|
60
|
+
from lucid.transport import (
|
|
61
|
+
Transport as Transport,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
__version__ = _version("lucid-graphql")
|
|
66
|
+
except PackageNotFoundError: # running from a source tree without an installed dist
|
|
67
|
+
__version__ = "0.0.0+unknown"
|
lucid/agent.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""The Strands agent that authors a query, plus the guardrails around it.
|
|
2
|
+
|
|
3
|
+
``stream_workflow`` is the single instrumented core: it yields progress
|
|
4
|
+
``Event``s while the agent works and returns the final workflow state. The
|
|
5
|
+
non-streaming entry points simply drain it, so the two can never diverge.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import contextvars
|
|
10
|
+
import logging
|
|
11
|
+
import queue
|
|
12
|
+
import threading
|
|
13
|
+
from collections import deque
|
|
14
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Generator, Iterator
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, cast
|
|
17
|
+
|
|
18
|
+
from graphql import GraphQLSchema
|
|
19
|
+
from strands import Agent
|
|
20
|
+
from strands.hooks import AfterToolCallEvent, BeforeToolCallEvent, HookProvider, HookRegistry
|
|
21
|
+
from strands.models import Model
|
|
22
|
+
from strands.types.exceptions import EventLoopException
|
|
23
|
+
|
|
24
|
+
from lucid.errors import LucidError, QueryExecutionError, QueryValidationError
|
|
25
|
+
from lucid.models import Event, RunMetrics
|
|
26
|
+
from lucid.tools import WorkflowState, build_tools
|
|
27
|
+
from lucid.transport import Transport
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("lucid")
|
|
30
|
+
|
|
31
|
+
_SYSTEM_PROMPT = """\
|
|
32
|
+
You write a single valid GraphQL query that answers the user's question.
|
|
33
|
+
|
|
34
|
+
Use the tools to explore the schema before writing the query. Never guess type
|
|
35
|
+
or field names — look them up with list_root_fields, search_schema and get_type.
|
|
36
|
+
|
|
37
|
+
Rules:
|
|
38
|
+
- Start from list_root_fields to find the right entry point.
|
|
39
|
+
- Select only the fields needed to answer the question; do not over-fetch.
|
|
40
|
+
- Fields whose type is an interface or union require inline fragments
|
|
41
|
+
(`... on ConcreteType`) for type-specific fields.
|
|
42
|
+
- You MUST call {goal_tool} with your query and only finish once it succeeds.
|
|
43
|
+
If it reports errors, fix the query and try again.
|
|
44
|
+
- When done, reply with exactly the final query text and nothing else.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
_NUDGE = (
|
|
48
|
+
"You have not yet produced a query that {goal}. "
|
|
49
|
+
"Use the tools to fix the query, call {goal_tool} until it succeeds, "
|
|
50
|
+
"then reply with the final query text."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _system_prompt(goal_tool: str, instructions: str | None) -> str:
|
|
55
|
+
prompt = _SYSTEM_PROMPT.format(goal_tool=goal_tool)
|
|
56
|
+
if instructions:
|
|
57
|
+
prompt += f"\nAdditional guidance from the application:\n{instructions}\n"
|
|
58
|
+
return prompt
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _IterationGuard(HookProvider):
|
|
62
|
+
"""Stops the run with a typed error once the correction cap is exhausted."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, state: WorkflowState, max_iterations: int) -> None:
|
|
65
|
+
self._state = state
|
|
66
|
+
self._max_iterations = max_iterations
|
|
67
|
+
|
|
68
|
+
def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
|
|
69
|
+
registry.add_callback(AfterToolCallEvent, self._after_tool_call)
|
|
70
|
+
|
|
71
|
+
def _after_tool_call(self, event: AfterToolCallEvent) -> None:
|
|
72
|
+
if isinstance(event.exception, LucidError):
|
|
73
|
+
# The tool executor re-invokes this hook with our own raise attached;
|
|
74
|
+
# re-raising is what makes it propagate out of the event loop.
|
|
75
|
+
raise event.exception
|
|
76
|
+
if event.exception is not None:
|
|
77
|
+
return
|
|
78
|
+
if self._state.validation_failures >= self._max_iterations:
|
|
79
|
+
raise QueryValidationError(
|
|
80
|
+
f"no valid query after {self._state.validation_failures} validation attempts",
|
|
81
|
+
query=self._state.last_query,
|
|
82
|
+
errors=self._state.last_errors,
|
|
83
|
+
)
|
|
84
|
+
if self._state.execution_failures >= self._max_iterations:
|
|
85
|
+
raise QueryExecutionError(
|
|
86
|
+
f"execution still failing after {self._state.execution_failures} attempts",
|
|
87
|
+
query=self._state.last_query,
|
|
88
|
+
errors=self._state.last_errors,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _tool_message(name: str, tool_input: dict[str, Any]) -> str:
|
|
93
|
+
match name:
|
|
94
|
+
case "list_root_fields":
|
|
95
|
+
return "listing the root query fields"
|
|
96
|
+
case "search_schema":
|
|
97
|
+
return f"searching the schema for {tool_input.get('keyword', '?')!r}"
|
|
98
|
+
case "get_type":
|
|
99
|
+
return f"inspecting type {tool_input.get('name', '?')}"
|
|
100
|
+
case "validate_query":
|
|
101
|
+
return "validating a candidate query"
|
|
102
|
+
case "execute_query":
|
|
103
|
+
return "executing the query"
|
|
104
|
+
return f"calling {name}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _EventCollector(HookProvider):
|
|
108
|
+
"""Translates tool-call hooks into ``tool`` and ``attempt`` events."""
|
|
109
|
+
|
|
110
|
+
_ATTEMPT_TOOLS = ("validate_query", "execute_query")
|
|
111
|
+
|
|
112
|
+
def __init__(self, state: WorkflowState, pending: deque[Event]) -> None:
|
|
113
|
+
self._state = state
|
|
114
|
+
self._pending = pending
|
|
115
|
+
self._attempts = 0
|
|
116
|
+
|
|
117
|
+
def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
|
|
118
|
+
registry.add_callback(BeforeToolCallEvent, self._before_tool_call)
|
|
119
|
+
registry.add_callback(AfterToolCallEvent, self._after_tool_call)
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _tool_input(event: BeforeToolCallEvent | AfterToolCallEvent) -> dict[str, Any]:
|
|
123
|
+
tool_input = event.tool_use.get("input")
|
|
124
|
+
return tool_input if isinstance(tool_input, dict) else {}
|
|
125
|
+
|
|
126
|
+
def _before_tool_call(self, event: BeforeToolCallEvent) -> None:
|
|
127
|
+
name = event.tool_use["name"]
|
|
128
|
+
self._pending.append(
|
|
129
|
+
Event(kind="tool", message=_tool_message(name, self._tool_input(event)))
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def _after_tool_call(self, event: AfterToolCallEvent) -> None:
|
|
133
|
+
if event.exception is not None or event.tool_use["name"] not in self._ATTEMPT_TOOLS:
|
|
134
|
+
return
|
|
135
|
+
self._attempts += 1
|
|
136
|
+
query = self._tool_input(event).get("query")
|
|
137
|
+
if self._state.last_errors:
|
|
138
|
+
error = "; ".join(self._state.last_errors)
|
|
139
|
+
message = f"attempt {self._attempts} failed: {error}"
|
|
140
|
+
else:
|
|
141
|
+
error = None
|
|
142
|
+
verb = "executed" if event.tool_use["name"] == "execute_query" else "validated"
|
|
143
|
+
message = f"attempt {self._attempts}: query {verb} successfully"
|
|
144
|
+
self._pending.append(
|
|
145
|
+
Event(
|
|
146
|
+
kind="attempt",
|
|
147
|
+
message=message,
|
|
148
|
+
iteration=self._attempts,
|
|
149
|
+
query=query,
|
|
150
|
+
error=error,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _metrics(agent: Agent, state: WorkflowState) -> RunMetrics:
|
|
156
|
+
usage = agent.event_loop_metrics.accumulated_usage
|
|
157
|
+
return RunMetrics(
|
|
158
|
+
input_tokens=usage["inputTokens"],
|
|
159
|
+
output_tokens=usage["outputTokens"],
|
|
160
|
+
total_tokens=usage["totalTokens"],
|
|
161
|
+
cycles=agent.event_loop_metrics.cycle_count,
|
|
162
|
+
iterations=state.validation_failures + state.execution_failures,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _goal_reached(state: WorkflowState, execute: bool) -> bool:
|
|
167
|
+
if execute:
|
|
168
|
+
return state.executed_query is not None
|
|
169
|
+
return state.validated_query is not None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _drain_async(items: AsyncIterator[dict[str, Any]]) -> Iterator[dict[str, Any]]:
|
|
173
|
+
"""Minimal sync bridge over the agent's async event stream (prototype).
|
|
174
|
+
|
|
175
|
+
The whole stream is consumed by a single task on a dedicated thread and
|
|
176
|
+
items are handed back over a queue. Step-driving the generator with one
|
|
177
|
+
``run_until_complete`` per item would resume it in a fresh Task (and thus
|
|
178
|
+
a fresh contextvars Context) each time, which corrupts context-scoped
|
|
179
|
+
state such as OpenTelemetry spans. The caller's context is carried into
|
|
180
|
+
the thread so tracing stays parented correctly.
|
|
181
|
+
"""
|
|
182
|
+
results: queue.Queue[tuple[str, Any]] = queue.Queue()
|
|
183
|
+
stop = threading.Event()
|
|
184
|
+
context = contextvars.copy_context()
|
|
185
|
+
|
|
186
|
+
async def consume() -> None:
|
|
187
|
+
try:
|
|
188
|
+
async for item in items:
|
|
189
|
+
results.put(("item", item))
|
|
190
|
+
if stop.is_set():
|
|
191
|
+
break
|
|
192
|
+
finally:
|
|
193
|
+
if isinstance(items, AsyncGenerator):
|
|
194
|
+
await items.aclose()
|
|
195
|
+
|
|
196
|
+
def run() -> None:
|
|
197
|
+
try:
|
|
198
|
+
# asyncio.run (vs a bare event loop) matters: it cancels pending
|
|
199
|
+
# tasks and finalizes abandoned async generators (e.g. provider
|
|
200
|
+
# SDK streams) via shutdown_asyncgens before closing the loop.
|
|
201
|
+
asyncio.run(consume())
|
|
202
|
+
results.put(("done", None))
|
|
203
|
+
except BaseException as error: # handed to the consuming thread to re-raise
|
|
204
|
+
results.put(("error", error))
|
|
205
|
+
|
|
206
|
+
thread = threading.Thread(target=lambda: context.run(run), name="lucid-stream", daemon=True)
|
|
207
|
+
thread.start()
|
|
208
|
+
try:
|
|
209
|
+
while True:
|
|
210
|
+
kind, value = results.get()
|
|
211
|
+
if kind == "done":
|
|
212
|
+
return
|
|
213
|
+
if kind == "error":
|
|
214
|
+
raise value
|
|
215
|
+
yield value
|
|
216
|
+
finally:
|
|
217
|
+
stop.set()
|
|
218
|
+
thread.join()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _stream_turn(agent: Agent, prompt: str, pending: deque[Event]) -> Generator[Event]:
|
|
222
|
+
"""Run one agent turn, yielding thinking/tool/attempt events as they happen."""
|
|
223
|
+
thinking: list[str] = []
|
|
224
|
+
|
|
225
|
+
def flush_thinking() -> Iterator[Event]:
|
|
226
|
+
text = "".join(thinking).strip()
|
|
227
|
+
thinking.clear()
|
|
228
|
+
if text:
|
|
229
|
+
yield Event(kind="thinking", message=text)
|
|
230
|
+
|
|
231
|
+
def flush_pending() -> Iterator[Event]:
|
|
232
|
+
yield from flush_thinking()
|
|
233
|
+
while pending:
|
|
234
|
+
yield pending.popleft()
|
|
235
|
+
|
|
236
|
+
stream = _drain_async(aiter(agent.stream_async(prompt)))
|
|
237
|
+
try:
|
|
238
|
+
for item in stream:
|
|
239
|
+
if pending:
|
|
240
|
+
yield from flush_pending()
|
|
241
|
+
if "reasoningText" in item:
|
|
242
|
+
thinking.append(str(item["reasoningText"]))
|
|
243
|
+
elif "data" in item:
|
|
244
|
+
thinking.append(str(item["data"]))
|
|
245
|
+
except EventLoopException as error:
|
|
246
|
+
if isinstance(error.original_exception, LucidError):
|
|
247
|
+
raise error.original_exception from error
|
|
248
|
+
raise
|
|
249
|
+
yield from flush_pending()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def stream_workflow(
|
|
253
|
+
question: str,
|
|
254
|
+
*,
|
|
255
|
+
schema: GraphQLSchema,
|
|
256
|
+
sdl_path: Path,
|
|
257
|
+
model: Model,
|
|
258
|
+
max_iterations: int,
|
|
259
|
+
transport: Transport | None = None,
|
|
260
|
+
instructions: str | None = None,
|
|
261
|
+
) -> Generator[Event, None, tuple[WorkflowState, RunMetrics]]:
|
|
262
|
+
"""Run the agentic workflow for one question, yielding progress events.
|
|
263
|
+
|
|
264
|
+
With a ``transport`` the goal is a successfully executed query (ask mode);
|
|
265
|
+
without one it is a successfully validated query (generate mode). Optional
|
|
266
|
+
``instructions`` are appended to the built-in system prompt to steer
|
|
267
|
+
generation (the workflow contract itself is not overridable). Returns
|
|
268
|
+
the final state and metrics via the generator's return value.
|
|
269
|
+
"""
|
|
270
|
+
execute = transport is not None
|
|
271
|
+
goal_tool = "execute_query" if execute else "validate_query"
|
|
272
|
+
state = WorkflowState()
|
|
273
|
+
pending: deque[Event] = deque()
|
|
274
|
+
agent = Agent(
|
|
275
|
+
model=model,
|
|
276
|
+
tools=cast(list[Any], build_tools(schema, sdl_path, state, transport=transport)),
|
|
277
|
+
system_prompt=_system_prompt(goal_tool, instructions),
|
|
278
|
+
hooks=[_IterationGuard(state, max_iterations), _EventCollector(state, pending)],
|
|
279
|
+
callback_handler=None,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
yield from _stream_turn(agent, question, pending)
|
|
283
|
+
nudges = 0
|
|
284
|
+
while not _goal_reached(state, execute) and nudges < max_iterations:
|
|
285
|
+
nudges += 1
|
|
286
|
+
logger.debug("goal not reached; nudging agent (attempt %d)", nudges)
|
|
287
|
+
goal = "executes successfully" if execute else "validates"
|
|
288
|
+
yield from _stream_turn(agent, _NUDGE.format(goal=goal, goal_tool=goal_tool), pending)
|
|
289
|
+
|
|
290
|
+
if not _goal_reached(state, execute):
|
|
291
|
+
if execute and state.validated_query is not None:
|
|
292
|
+
raise QueryExecutionError(
|
|
293
|
+
"agent finished without successfully executing a query",
|
|
294
|
+
query=state.last_query,
|
|
295
|
+
errors=state.last_errors,
|
|
296
|
+
)
|
|
297
|
+
raise QueryValidationError(
|
|
298
|
+
"agent finished without a validated query",
|
|
299
|
+
query=state.last_query,
|
|
300
|
+
errors=state.last_errors,
|
|
301
|
+
)
|
|
302
|
+
return state, _metrics(agent, state)
|
lucid/cli.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Command-line interface: ask a GraphQL endpoint a question.
|
|
2
|
+
|
|
3
|
+
Runnable via ``uvx --from "lucid-graphql[anthropic]" lucid ...`` or after
|
|
4
|
+
``uv tool install "lucid-graphql[anthropic]"``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from lucid.client import create
|
|
12
|
+
from lucid.errors import LucidError
|
|
13
|
+
from lucid.models import Event
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command()
|
|
17
|
+
@click.argument("question")
|
|
18
|
+
@click.option("--url", "-u", required=True, help="The GraphQL endpoint.")
|
|
19
|
+
@click.option(
|
|
20
|
+
"--generate",
|
|
21
|
+
"-g",
|
|
22
|
+
"generate_only",
|
|
23
|
+
is_flag=True,
|
|
24
|
+
help="Print the generated query instead of executing it.",
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
"--verbose",
|
|
28
|
+
"-v",
|
|
29
|
+
is_flag=True,
|
|
30
|
+
help="Write the agent's progress (thinking, tools, attempts) to stderr.",
|
|
31
|
+
)
|
|
32
|
+
def main(question: str, url: str, generate_only: bool, verbose: bool) -> None:
|
|
33
|
+
"""Turn a natural-language QUESTION into a GraphQL query and run it against --url.
|
|
34
|
+
|
|
35
|
+
Prints the query result as JSON (or, with --generate, the query itself)
|
|
36
|
+
to stdout. Uses the default Anthropic model; set ANTHROPIC_API_KEY.
|
|
37
|
+
"""
|
|
38
|
+
# ask/generate are just their stream drained, so the stream form with
|
|
39
|
+
# on_event=None is identical to the plain call — no need to branch.
|
|
40
|
+
on_event = _echo_progress if verbose else None
|
|
41
|
+
try:
|
|
42
|
+
client = create(url)
|
|
43
|
+
if generate_only:
|
|
44
|
+
click.echo(client.generate_stream(question, on_event=on_event).result())
|
|
45
|
+
else:
|
|
46
|
+
response = client.ask_stream(question, on_event=on_event).result()
|
|
47
|
+
click.echo(json.dumps(response.data, indent=2))
|
|
48
|
+
except LucidError as error:
|
|
49
|
+
click.echo(f"Error: {error}", err=True)
|
|
50
|
+
raise SystemExit(1) from error
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _echo_progress(event: Event) -> None:
|
|
54
|
+
click.echo(event.message, err=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|