|
2 | 2 | import os |
3 | 3 |
|
4 | 4 | from crewai import LLM, Agent, Crew, Task |
5 | | -from crewai.tools import tool |
| 5 | +from crewai_tools import ApifyActorsTool |
| 6 | +from pydantic import BaseModel |
6 | 7 |
|
7 | 8 | from apify import Actor |
8 | 9 |
|
9 | 10 | OPENROUTER_BASE_URL = 'https://openrouter.apify.actor/api/v1' |
10 | 11 |
|
11 | 12 | # On a fresh container, CrewAI shows a one-time trace-consent prompt that blocks on |
12 | | -# stdin. `CREWAI_TESTING=true` is the only flag that suppresses it. |
| 13 | +# stdin. `CREWAI_TESTING=true` suppresses it. |
13 | 14 | os.environ.setdefault('CREWAI_TESTING', 'true') |
14 | 15 |
|
| 16 | +# The Crawlee docs page the crew reads when the input has no `url`. |
| 17 | +DEFAULT_URL = 'https://crawlee.dev/python/docs/guides/architecture-overview' |
15 | 18 |
|
16 | | -@tool('Average') |
17 | | -def average(numbers: list[float]) -> float: |
18 | | - """Return the arithmetic mean of a list of numbers.""" |
19 | | - return sum(numbers) / len(numbers) |
| 19 | + |
| 20 | +class ActorInput(BaseModel): |
| 21 | + """The Actor input, validated with default values.""" |
| 22 | + |
| 23 | + url: str = DEFAULT_URL |
| 24 | + model: str = 'openai/gpt-5.4-mini' |
| 25 | + |
| 26 | + |
| 27 | +class Crawler(BaseModel): |
| 28 | + """One crawler class that Crawlee provides.""" |
| 29 | + |
| 30 | + name: str |
| 31 | + built_on: str |
| 32 | + best_for: str |
| 33 | + |
| 34 | + |
| 35 | +class CrawlerGuide(BaseModel): |
| 36 | + """The structured guide the crew distills from the docs page.""" |
| 37 | + |
| 38 | + crawlers: list[Crawler] |
20 | 39 |
|
21 | 40 |
|
22 | 41 | async def main() -> None: |
23 | 42 | async with Actor: |
24 | | - actor_input = await Actor.get_input() or {} |
25 | | - query = actor_input.get('query', 'What is the average of 12, 18, and 30?') |
26 | | - model = actor_input.get('model', 'openai/gpt-4o-mini') |
| 43 | + # Parse the Actor input into the typed model, filling in defaults. |
| 44 | + actor_input = ActorInput.model_validate(await Actor.get_input() or {}) |
| 45 | + url = actor_input.url |
| 46 | + model = actor_input.model |
27 | 47 |
|
28 | 48 | # Route the LLM through the Apify OpenRouter proxy (no provider key needed). |
29 | | - # The `openai/` prefix selects CrewAI's OpenAI-compatible client. The rest |
30 | | - # is the OpenRouter model slug sent to the proxy. |
| 49 | + # The `openai/` prefix selects CrewAI's OpenAI-compatible client. |
31 | 50 | llm = LLM( |
32 | 51 | model=f'openai/{model}', |
33 | 52 | base_url=OPENROUTER_BASE_URL, |
34 | 53 | api_key=os.environ['APIFY_TOKEN'], |
35 | 54 | ) |
36 | 55 |
|
37 | | - # A one-agent crew: an analyst that answers using the `average` tool. |
38 | | - analyst = Agent( |
39 | | - role='Data Analyst', |
40 | | - goal='Answer numeric questions accurately.', |
41 | | - backstory='An analyst who turns raw numbers into clear answers.', |
42 | | - tools=[average], |
| 56 | + # `ApifyActorsTool` exposes any Apify Actor as a CrewAI tool. Here it wraps the |
| 57 | + # RAG Web Browser to fetch the page as clean Markdown. |
| 58 | + researcher = Agent( |
| 59 | + role='Documentation researcher', |
| 60 | + goal='Read the Crawlee docs and note every crawler it describes.', |
| 61 | + backstory='A researcher who reads technical docs closely.', |
| 62 | + tools=[ApifyActorsTool('apify/rag-web-browser')], |
| 63 | + llm=llm, |
| 64 | + ) |
| 65 | + writer = Agent( |
| 66 | + role='Technical writer', |
| 67 | + goal='Turn research notes into a clear, structured crawler guide.', |
| 68 | + backstory='A writer who distills docs into comparison tables.', |
43 | 69 | llm=llm, |
44 | 70 | ) |
45 | | - task = Task( |
46 | | - description=query, |
47 | | - expected_output='A short, readable answer to the query.', |
48 | | - agent=analyst, |
| 71 | + |
| 72 | + research = Task( |
| 73 | + description=f'Scrape {url} and list the crawlers the page covers.', |
| 74 | + expected_output='Notes on each crawler: name, what it builds on, its use.', |
| 75 | + agent=researcher, |
| 76 | + ) |
| 77 | + # `context=[research]` feeds the researcher's notes to the writer, and |
| 78 | + # `output_pydantic` makes the final task return a validated `CrawlerGuide`. |
| 79 | + write = Task( |
| 80 | + description=( |
| 81 | + 'From the notes, compile each crawler with what it is built on ' |
| 82 | + 'and what it is best for.' |
| 83 | + ), |
| 84 | + expected_output='A list of crawlers with name, built_on, and best_for.', |
| 85 | + agent=writer, |
| 86 | + context=[research], |
| 87 | + output_pydantic=CrawlerGuide, |
49 | 88 | ) |
50 | 89 |
|
51 | | - # `kickoff_async` keeps the Actor's event loop responsive. |
52 | | - result = await Crew(agents=[analyst], tasks=[task]).kickoff_async() |
53 | | - Actor.log.info(f'Crew result:\n{result.raw}') |
54 | | - await Actor.push_data({'query': query, 'answer': result.raw}) |
| 90 | + # `kickoff_async` runs the crew without blocking the Actor's event loop. |
| 91 | + crew = Crew(agents=[researcher, writer], tasks=[research, write]) |
| 92 | + guide = (await crew.kickoff_async()).pydantic |
| 93 | + if guide is None: |
| 94 | + raise RuntimeError('The crew did not return a structured CrawlerGuide.') |
| 95 | + Actor.log.info(f'Crawler guide:\n{guide.model_dump_json(indent=2)}') |
| 96 | + await Actor.push_data([crawler.model_dump() for crawler in guide.crawlers]) |
55 | 97 |
|
56 | 98 |
|
57 | 99 | if __name__ == '__main__': |
|
0 commit comments