artificial-intelligence-local 0.0.2b3115__tar.gz

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.
Files changed (20) hide show
  1. artificial_intelligence_local-0.0.2b3115/PKG-INFO +29 -0
  2. artificial_intelligence_local-0.0.2b3115/README.md +150 -0
  3. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/__init__.py +0 -0
  4. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/src/__init__.py +0 -0
  5. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/src/contact_agent.py +264 -0
  6. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/src/ollama_agent_base.py +204 -0
  7. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/src/weather_agent.py +112 -0
  8. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/tests/__init__.py +0 -0
  9. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/tests/artificial_intelligence_local_test.py +10 -0
  10. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/tests/contact_agent_test.py +275 -0
  11. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/tests/ollama_agent_base_test.py +124 -0
  12. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/tests/weather_agent_test.py +83 -0
  13. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local.egg-info/PKG-INFO +29 -0
  14. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local.egg-info/SOURCES.txt +18 -0
  15. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local.egg-info/dependency_links.txt +1 -0
  16. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local.egg-info/requires.txt +7 -0
  17. artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local.egg-info/top_level.txt +1 -0
  18. artificial_intelligence_local-0.0.2b3115/pyproject.toml +29 -0
  19. artificial_intelligence_local-0.0.2b3115/setup.cfg +4 -0
  20. artificial_intelligence_local-0.0.2b3115/setup.py +84 -0
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: artificial-intelligence-local
3
+ Version: 0.0.2b3115
4
+ Summary: PyPI artificial-intelligence-local Python Package owned by Circlez.ai
5
+ Home-page: https://github.com/circles-zone/artificial-intelligence-local-python-package
6
+ Author: Circles
7
+ Author-email: info@circlez.ai
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: python-sdk-remote
12
+ Requires-Dist: logger-local>=0.0.189
13
+ Requires-Dist: strands-agents
14
+ Requires-Dist: ollama
15
+ Requires-Dist: python-dotenv
16
+ Requires-Dist: requests
17
+ Requires-Dist: contact-local>=0.0.72b3047
18
+ Dynamic: author
19
+ Dynamic: author-email
20
+ Dynamic: classifier
21
+ Dynamic: description
22
+ Dynamic: description-content-type
23
+ Dynamic: home-page
24
+ Dynamic: requires-dist
25
+ Dynamic: summary
26
+
27
+ PyPI artificial-intelligence-local Python Package owned by Circlez.ai
28
+ JIRA Work Item: https://circles-zone.atlassian.net/browse/BU-3046
29
+ GHA: https://github.com/circles-zone/artificial-intelligence-local-python-package/actions
@@ -0,0 +1,150 @@
1
+ # General
2
+
3
+ Unlike the README.md in the root directory, this is a README.md of this specific repo/package<br>
4
+
5
+ TODO Please update this README.md of this specific repo/package and not the README.md in the root directory based on the python-package-template repo<br>
6
+
7
+ ## TODOs
8
+
9
+ ### Implement using AWS Strands Agents SDK wrapper classes<br>
10
+
11
+ All the classes below should get an optional parameter of OurAgentSdkEnum, which at this point has only one value: AWS_STRANDS_AGENTS_SDK<br>
12
+
13
+ TODO Create OurLlmModel class, which at this point inherits "stands.models.litellm" LiteLLMModel. Has model_id data member<br>
14
+ TODO Create OurLlmModelWithSystemPrompt class which inherits OurLlmModel and has system_prompt data member<br>
15
+ TODO Create OurLlmPrompt class, which prompt str data member<br>
16
+ TODO Create OurLlmComplete method<br>
17
+ TODO Create OurLlmResponse class with which the same data memresponse str data member<br>
18
+ TODO Create OurLlmConversation class which includes Context<br>
19
+
20
+ TODO Create OurLlmAgent class with inheritance at this point stands Agent<br>
21
+ TODO Create OurLlmSession class, which at this point inherits strands.session.s3_session_manager S3SessionManager (it can recieve optional OurStorage parameter i.e. S3) uses our storage-local-python-package repo<br>
22
+ TODO Create OurLlmMemory (knowledgebase) Class which uses SmartVectordb class from our smart-datastore-python-package repo<br>
23
+ TODO Create OurLlmGuardrails class that imports strands.models BedrockModel and uses BedrockModel( guardrail_id and guardrail_version and guardrail_trace )<br>
24
+ TODO Create OurLlmMcpClient, which at this point inherits strands.tools.mcp MCPClient<br>
25
+
26
+ TODO Natural 2 KQL
27
+ TODO Natural 2 SQL
28
+
29
+ TODO Convert https://github.com/gilinachum/agents/blob/main/strands-beginner.ipynb to use OurLlmModel, OurLlmPrompt (prompt-local-python-package), OurLlmResponse, OurLlmConversation, OurLlmAgent, OurLlmSession, OurLlmMemory, OurLlmGuardrails in this repo<br>
30
+ TODO Implement https://github.com/microsoft/langchain-for-beginners in prompt-local-python-package using inheritance to allow also other implementations.<br>
31
+
32
+ TODO Develop OurLlmRouter based on OpenRouter, AWS Bedrock
33
+
34
+ TODO Wrapper around OpenRouter to gain API for Any AI Model
35
+
36
+ ### Implement using OpenAI's Agent SDK
37
+
38
+ You can start by working with an OpenAI-compatible model running locally with LMStudio running locally<br>
39
+
40
+ ### Implement using Google's Agent Development Kit (ADK)
41
+
42
+ ### Implement using LangChain Agents
43
+
44
+ ) using machine learning model, Smart Vector Store (smart_vectorstore.py)<br>
45
+
46
+ # Running the local weather agent
47
+
48
+ An example agent lives at `artificial_intelligence_local/src/weather_agent.py`. It runs a local
49
+ LLM via [Ollama](https://ollama.com) and answers US weather questions from the National Weather
50
+ Service API.
51
+
52
+ Following the tool-based pattern in the [Strands samples](https://github.com/strands-agents/samples)
53
+ repo, the National Weather Service two-step call (`points` → `forecast`) lives inside a Python
54
+ `get_forecast(latitude, longitude)` tool. The model's only job is to pick the location's
55
+ coordinates, call the tool, and phrase the one-line answer — which is far more reliable on small
56
+ local models than asking the model to build API URLs and parse JSON itself.
57
+
58
+ ## Prerequisites
59
+
60
+ 1. Install Ollama: https://ollama.com/download — then start it (the Ollama app, or `ollama serve`).
61
+ 2. Pull a model that supports tool-calling, e.g.: `ollama pull qwen2.5:7b`
62
+ 3. Copy `.env.local1.example` to `.env.local1` and set `OLLAMA_HOST` / `OLLAMA_MODEL_ID` for your machine.
63
+
64
+ ## Run
65
+
66
+ Run these from the package directory. Note the repo nests a same-named folder, so from the
67
+ cloned repository root you first need to `cd` one level in:
68
+
69
+ ```bash
70
+ # From the repository root:
71
+ cd artificial-intelligence-local-python-package # the inner package dir (skip if already there)
72
+
73
+ python -m venv .venv && source .venv/bin/activate
74
+ pip install -r requirements.txt
75
+ python artificial_intelligence_local/src/weather_agent.py
76
+ ```
77
+
78
+ On startup the agent lists the Ollama models you have installed and lets you pick one by number
79
+ (press Enter to accept the default). Then ask, e.g., `What's the weather like in Seattle?` (type
80
+ `exit` to quit).
81
+
82
+ The pre-selected default comes from `.env.local1` (`OLLAMA_MODEL_ID`). `qwen2.5:7b` is the default
83
+ and handles the coordinate-lookup + tool-call reliably; smaller models like `llama3.2:3b` also work
84
+ but are more likely to guess coordinates poorly. In non-interactive runs (tests / CI) the default is
85
+ used without prompting.
86
+
87
+ # Running the local contact agent
88
+
89
+ `artificial_intelligence_local/src/contact_agent.py` is the same pattern applied to our contacts
90
+ database. Its tools wrap the read-only `_strand` twins published by
91
+ [contact-local](https://pypi.org/project/contact-local/) (`contact_local/contact_local_strands.py`),
92
+ so a local Ollama model can answer questions about contacts.
93
+
94
+ The `@tool` decorator lives here rather than in contact-main on purpose: contact-main stays free of
95
+ any Strands dependency, and this package decides how its functions are exposed to a model. The tools
96
+ take only flat scalar arguments — the strand twins speak `contact_dict`, but a small local model
97
+ cannot be trusted to synthesize a dict (it invents keys), so where an answer needs two strand calls
98
+ the tool composes them internally.
99
+
100
+ ## What it can and cannot answer
101
+
102
+ Lookup is by **contact id, phone number, or email address**:
103
+
104
+ - `Who is contact 1?`
105
+ - `What's the phone number of contact 1?`
106
+ - `Do we already have someone with the email tal@circ.zone?`
107
+
108
+ It **cannot look up a contact by name** — `ContactsLocal` has no name-based lookup
109
+ (`get_existing_contact_dict` matches on phone/email only). Asked "What's Tal's phone number?", the
110
+ agent says it can only look someone up by id / phone / email rather than guessing an id. Adding name
111
+ lookup needs `get_contact_by_name` in contact-main first, which also has to handle first+last names
112
+ not being unique (BU-3058).
113
+
114
+ ## Prerequisites
115
+
116
+ The Ollama prerequisites above, plus — because every tool call goes through contact-local to the
117
+ database — the local backend:
118
+
119
+ 1. A local MySQL with the contact schema.
120
+ 2. The local authentication server on `http://localhost:3000`: from
121
+ `authentication-local-restapi-typescript-serverless-com`, run `npx serverless offline --stage local`.
122
+ (Note `serverless.ts` calls a bare `dotenv.config()`, which only reads a file named exactly `.env`,
123
+ so the `RDS_*` / `JWT_*` variables have to be exported in your shell or copied into `.env`.)
124
+
125
+ Importing `contact_local` performs an auth login *at import time*, which is why `contact_agent.py`
126
+ imports it lazily inside the tools — the module and its tests import fine without any of this
127
+ running, and only an actual lookup needs it.
128
+
129
+ ## Run
130
+
131
+ ```bash
132
+ # From the inner package directory, as above:
133
+ python artificial_intelligence_local/src/contact_agent.py
134
+ ```
135
+
136
+ Then ask, e.g., `What's the phone number of contact 1?` (type `exit` to quit).
137
+
138
+ # Versions
139
+
140
+ [pub] 0.0.1 Initial version (change the directories, files, setup.py, .github/workflows/*.yml)<br>
141
+
142
+ # Installing instructions
143
+
144
+ It's advised to use venv<br>
145
+ If you have Windows, it's also advised to use Windows Subsystem for Linux<br>
146
+
147
+ ## Install dependencies
148
+
149
+ Run ./download-beta-sdk.sh from the bash terminal<br>
150
+ Run pip install -r requirements.txt from the bash terminal<br>
@@ -0,0 +1,264 @@
1
+ """Ollama contacts agent.
2
+
3
+ A local-first Strands agent over our contacts, built on the same pattern as the
4
+ weather agent (see weather_agent.py): the data access lives in Python `@tool`
5
+ functions and a local Ollama model only picks the tool, fills in its arguments,
6
+ and phrases the answer.
7
+
8
+ The tools are thin wrappers over the read-only "_strand" twins published by
9
+ contact-local (contact_local/contact_local_strands.py, BU-3047). The `@tool`
10
+ decorator lives here rather than in contact-main on purpose: contact-main stays
11
+ free of any Strands dependency, and this package decides how the functions are
12
+ exposed to a model.
13
+
14
+ Two deliberate constraints shape the tools below:
15
+
16
+ 1. Flat, scalar arguments only. The strand twins speak `contact_dict`, but a
17
+ dict is a terrible thing to ask a small local model to synthesize -- it will
18
+ invent keys. So no tool takes a dict: where an answer needs two strand calls
19
+ (fetch the contact, then read a field off it), the tool composes them
20
+ internally and the model just passes a contact id.
21
+
22
+ 2. Lookup is by contact id, phone, or email -- NOT by name. ContactsLocal has no
23
+ name-based lookup (`get_existing_contact_dict` matches on phone/email only),
24
+ so the agent cannot answer "What's Tal's phone number?" and the system prompt
25
+ below tells it to say so instead of guessing. Name lookup needs
26
+ `get_contact_by_name` added to contact-main first (it also has to deal with
27
+ first+last names not being unique -- BU-3058).
28
+ """
29
+
30
+ from strands import tool
31
+
32
+ from ollama_agent_base import DEFAULT_OLLAMA_MODEL_ID, build_ollama_agent, run_chat_loop
33
+
34
+
35
+ def _strand_functions():
36
+ """Return the contact-local strand functions, imported lazily.
37
+
38
+ Importing contact_local pulls in person_local, which logs in against the
39
+ local authentication server (http://localhost:3000) *at import time*.
40
+ Keeping that import inside the call means this module -- and its tests --
41
+ can be imported with no database and no auth server running; only an actual
42
+ lookup needs them. It is also the seam the tests mock.
43
+ """
44
+ from contact_local import contact_local_strands
45
+
46
+ return contact_local_strands
47
+
48
+
49
+ def _lookup_failed(error: Exception) -> str:
50
+ """The message a tool returns when the contacts backend could not be reached.
51
+
52
+ Kept short, and deliberately free of the underlying exception text. Passing
53
+ the raw error back (e.g. the auth server's 500, which is a wall of JSON) led
54
+ a small local model to compress it into "There is no contact with id 1" --
55
+ turning a backend outage into a confident, wrong "that person doesn't
56
+ exist". The wording below tells the model what the failure is NOT.
57
+ """
58
+ return (
59
+ f"LOOKUP FAILED ({type(error).__name__}): the contacts database could not be reached. "
60
+ "This is a system error. It does NOT mean the contact does not exist -- "
61
+ "tell the user the contacts database is unavailable right now."
62
+ )
63
+
64
+
65
+ def _format_contact(contact_dict: dict) -> str:
66
+ """Describe who a contact is, for the model to read."""
67
+ name = " ".join(
68
+ part for part in (contact_dict.get("first_name"), contact_dict.get("last_name")) if part
69
+ )
70
+ description = name or contact_dict.get("display_as") or "Unnamed contact"
71
+
72
+ for field in ("job_title", "organization"):
73
+ value = contact_dict.get(field)
74
+ if value:
75
+ description += f", {value}"
76
+ return description
77
+
78
+
79
+ @tool
80
+ def get_contact_by_id(contact_id: int) -> str:
81
+ """Get who a contact is (name, job title, organization) from their contact id.
82
+
83
+ Args:
84
+ contact_id: The numeric id of the contact, e.g. 42.
85
+
86
+ Returns:
87
+ A short description of the contact, or a message saying no contact has
88
+ that id.
89
+ """
90
+ try:
91
+ contact_dict = _strand_functions().get_contact_by_contact_id_strand(contact_id)
92
+ except Exception as error: # noqa: BLE001 - report the failure to the model, never raise at it
93
+ return _lookup_failed(error)
94
+
95
+ if not contact_dict:
96
+ return f"There is no contact with id {contact_id}."
97
+ return f"Contact {contact_id}: {_format_contact(contact_dict)}."
98
+
99
+
100
+ @tool
101
+ def get_contact_phone_numbers(contact_id: int) -> str:
102
+ """Get the phone numbers of the contact with this contact id.
103
+
104
+ Args:
105
+ contact_id: The numeric id of the contact, e.g. 42.
106
+
107
+ Returns:
108
+ The contact's phone numbers, or a message saying they have none on
109
+ record / that no contact has that id.
110
+ """
111
+ try:
112
+ strands = _strand_functions()
113
+ contact_dict = strands.get_contact_by_contact_id_strand(contact_id)
114
+ if not contact_dict:
115
+ return f"There is no contact with id {contact_id}."
116
+ phone_numbers = strands.get_contact_phone_numbers_from_contact_dict_strand(contact_dict)
117
+ except Exception as error: # noqa: BLE001 - report the failure to the model, never raise at it
118
+ return _lookup_failed(error)
119
+
120
+ if not phone_numbers:
121
+ return f"{_format_contact(contact_dict)} has no phone number on record."
122
+ return f"{_format_contact(contact_dict)}: {', '.join(phone_numbers)}"
123
+
124
+
125
+ @tool
126
+ def get_contact_email_addresses(contact_id: int) -> str:
127
+ """Get the email addresses of the contact with this contact id.
128
+
129
+ Args:
130
+ contact_id: The numeric id of the contact, e.g. 42.
131
+
132
+ Returns:
133
+ The contact's email addresses, or a message saying they have none on
134
+ record / that no contact has that id.
135
+ """
136
+ try:
137
+ strands = _strand_functions()
138
+ contact_dict = strands.get_contact_by_contact_id_strand(contact_id)
139
+ if not contact_dict:
140
+ return f"There is no contact with id {contact_id}."
141
+ email_addresses = strands.get_contact_email_addresses_from_contact_dict_strand(contact_dict)
142
+ except Exception as error: # noqa: BLE001 - report the failure to the model, never raise at it
143
+ return _lookup_failed(error)
144
+
145
+ if not email_addresses:
146
+ return f"{_format_contact(contact_dict)} has no email address on record."
147
+ return f"{_format_contact(contact_dict)}: {', '.join(email_addresses)}"
148
+
149
+
150
+ @tool
151
+ def find_contact_by_phone_or_email(phone_number: str = "", email_address: str = "") -> str:
152
+ """Check whether we already have a contact with this phone number or email address.
153
+
154
+ Use this to answer questions like "do we already know someone with this
155
+ email?". Give a phone number, an email address, or both.
156
+
157
+ Args:
158
+ phone_number: A phone number to search for, e.g. "+972501234567".
159
+ email_address: An email address to search for, e.g. "tal@circ.zone".
160
+
161
+ Returns:
162
+ A description of the matching contact including its contact id (which
163
+ can then be used with the other tools), or a message saying nobody
164
+ matches.
165
+ """
166
+ if not phone_number and not email_address:
167
+ return "Give me a phone number or an email address to search for."
168
+
169
+ # The strand twin matches on the phone/email columns of a contact_dict, so we
170
+ # compose that dict here -- the model only ever passes flat strings.
171
+ search_contact_dict = {}
172
+ if phone_number:
173
+ search_contact_dict["phone1"] = phone_number
174
+ if email_address:
175
+ search_contact_dict["email1"] = email_address
176
+
177
+ try:
178
+ contact_dict = _strand_functions().get_existing_contact_dict_strand(search_contact_dict)
179
+ except Exception as error: # noqa: BLE001 - report the failure to the model, never raise at it
180
+ return _lookup_failed(error)
181
+
182
+ if not contact_dict:
183
+ searched = " or ".join(value for value in (phone_number, email_address) if value)
184
+ return f"We have no contact with {searched}."
185
+
186
+ contact_id = contact_dict.get("contact_id")
187
+ return f"Yes -- contact {contact_id}: {_format_contact(contact_dict)}."
188
+
189
+
190
+ # The prompt does not list the tools: Strands already sends the model each tool's name,
191
+ # signature and docstring, so a list here would be a second copy that goes stale the
192
+ # moment a tool is added. Describe a tool in its docstring, not here.
193
+ #
194
+ # The name rule below is the exception, and it is deliberate. A tool schema can only say
195
+ # what EXISTS -- an absence is not expressible in any schema, so the model cannot infer
196
+ # it. Measured on qwen2.5:7b with "What's Tal's phone number?": with the explicit rule,
197
+ # 6/6 clean refusals; replacing it with a general "ask for an identifier you can look up",
198
+ # 0/6 -- the model reaches for the tool named find_contact_by_phone_or_email, gets
199
+ # nothing, then guesses contact_id=1 and reports a stranger's phone number as Tal's.
200
+ #
201
+ # TODO (when name lookup lands -- BU-3058): DELETE this rule, do not just edit it. It does
202
+ # not merely go stale, it SUPPRESSES the new tool: measured with a working name tool
203
+ # registered AND this rule still in the prompt, the model called no tool at all and told
204
+ # the user it cannot look people up by name. Tests stay green while the feature silently
205
+ # does nothing. The general prompt is fine once the gap itself is a registered tool -- a
206
+ # find_contact_by_name whose body reports "name lookup unavailable" scored 6/6 with no
207
+ # name rule in the prompt at all. That is the shape to move to once the tools are complete.
208
+ CONTACT_SYSTEM_PROMPT = """You are an assistant that answers questions about our contacts database.
209
+
210
+ Rules -- follow them strictly:
211
+ - Answer ONLY from what a tool returned. Never invent a name, phone number, email
212
+ address or contact id, and never guess an id to "try".
213
+ - You CANNOT look up a contact by name -- there is no tool for it. If you are asked
214
+ something like "What is Tal's phone number?", do not guess: say you can only look
215
+ someone up by contact id, phone number, or email address, and ask for one of those.
216
+ - If a tool says there is no such contact, say exactly that. Do not retry with a
217
+ different id.
218
+ - If a tool answer starts with "LOOKUP FAILED", the contacts database is broken or
219
+ unreachable. Say that the contacts database is unavailable right now. NEVER report
220
+ this as the contact not existing -- a failed lookup tells you NOTHING about whether
221
+ the contact exists.
222
+ - Do not narrate your steps or say what you are "about to" do. Call the tool, then
223
+ give only the final answer, in one or two short sentences.
224
+ """
225
+
226
+
227
+ def build_agent(model_id=DEFAULT_OLLAMA_MODEL_ID):
228
+ """Build the contacts Agent backed by a local Ollama model.
229
+
230
+ Kept as a factory so the interactive picker can build the agent with the
231
+ model the user chose at startup, while imports/tests still get a ready-made
232
+ agent from the configured default (see ``contact_agent`` below).
233
+ """
234
+ return build_ollama_agent(
235
+ model_id=model_id,
236
+ system_prompt=CONTACT_SYSTEM_PROMPT,
237
+ tools=[
238
+ get_contact_by_id,
239
+ get_contact_phone_numbers,
240
+ get_contact_email_addresses,
241
+ find_contact_by_phone_or_email,
242
+ ],
243
+ )
244
+
245
+
246
+ # A ready-made agent on the configured default, so importing the module (and the
247
+ # tests) get a working agent without any interactive prompt. Building the agent
248
+ # touches neither the database nor Ollama -- see _strand_functions.
249
+ contact_agent = build_agent()
250
+
251
+
252
+ # Interactive loop so we can test it from the terminal. Needs the database and the
253
+ # local authentication server (http://localhost:3000) running, since every tool
254
+ # call goes through contact-local.
255
+ if __name__ == "__main__":
256
+ run_chat_loop(
257
+ build_agent=build_agent,
258
+ title="Ollama Contact Agent",
259
+ examples=[
260
+ "Who is contact 1?",
261
+ "What's the phone number of contact 1?",
262
+ "Do we already have someone with the email tal@circ.zone?",
263
+ ],
264
+ )
@@ -0,0 +1,204 @@
1
+ """Shared Ollama + Strands plumbing for the local agents in this package.
2
+
3
+ The weather agent and the contact agent have the same shape: a local Ollama
4
+ model plus a few Python `@tool` functions. Everything that is not specific to
5
+ one agent -- building the Strands Agent on an OllamaModel, listing the models
6
+ you have pulled, picking one at startup, and the terminal chat loop -- lives
7
+ here, so an agent module only declares its tools and its system prompt.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+
13
+ import ollama
14
+ from dotenv import find_dotenv, load_dotenv
15
+ from strands import Agent
16
+ from strands.models.ollama import OllamaModel
17
+
18
+ # Load OLLAMA_* settings from a local .env.local1 file if one exists
19
+ # (see .env.local1.example). find_dotenv searches up from the current working
20
+ # directory, so this works whichever sub-directory you launch an agent from.
21
+ load_dotenv(find_dotenv(".env.local1", usecwd=True))
22
+
23
+ # The model (the "brain"): a local Ollama model instead of a cloud provider.
24
+ # You must have Ollama installed and the model pulled first:
25
+ # `ollama pull <model_id>`. Running an agent interactively lets you pick a
26
+ # different installed model at startup (see choose_model).
27
+ DEFAULT_OLLAMA_MODEL_ID = os.getenv("OLLAMA_MODEL_ID", "qwen2.5:7b")
28
+ DEFAULT_OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
29
+
30
+
31
+ def build_ollama_agent(model_id, system_prompt, tools):
32
+ """Build a Strands Agent backed by a local Ollama model.
33
+
34
+ Args:
35
+ model_id: An Ollama model you have pulled, e.g. "qwen2.5:7b".
36
+ system_prompt: The agent's instructions.
37
+ tools: The `@tool`-decorated functions the agent is allowed to call.
38
+ """
39
+ model = OllamaModel(host=DEFAULT_OLLAMA_HOST, model_id=model_id)
40
+ return Agent(model=model, system_prompt=system_prompt, tools=tools)
41
+
42
+
43
+ def available_ollama_models():
44
+ """Return the locally-pulled Ollama model ids, e.g. ``["qwen2.5:7b", ...]``.
45
+
46
+ Uses the Ollama Python client. Returns an empty list if Ollama is not
47
+ running or has no models pulled, so callers can fall back to the default.
48
+ """
49
+ try:
50
+ response = ollama.list()
51
+ except Exception: # noqa: BLE001 - Ollama may be down; caller falls back to the default
52
+ return []
53
+ # ollama-python >= 0.4 returns an object with a ``.models`` list of items
54
+ # exposing ``.model``; older versions return a dict of {"name"/"model": ...}.
55
+ models = getattr(response, "models", None)
56
+ if models is None and isinstance(response, dict):
57
+ models = response.get("models", [])
58
+ names = []
59
+ for model in models or []:
60
+ name = getattr(model, "model", None)
61
+ if name is None and isinstance(model, dict):
62
+ name = model.get("model") or model.get("name")
63
+ if name:
64
+ names.append(name)
65
+ return names
66
+
67
+
68
+ def is_ollama_running():
69
+ """Whether the Ollama server answers at DEFAULT_OLLAMA_HOST."""
70
+ try:
71
+ ollama.list()
72
+ except Exception: # noqa: BLE001 - any failure to reach Ollama means "not running"
73
+ return False
74
+ return True
75
+
76
+
77
+ def ollama_readiness_problem(model_id):
78
+ """Explain what is wrong with the Ollama setup, or return None if it is ready.
79
+
80
+ Without this the failure surfaces as a raw ConnectionError from deep inside
81
+ the Strands/Ollama stack, which tells the user nothing about what to fix.
82
+ Each of the three ways this goes wrong needs a different fix, so they get
83
+ different messages.
84
+ """
85
+ if not is_ollama_running():
86
+ return (
87
+ f"Cannot reach Ollama at {DEFAULT_OLLAMA_HOST}.\n"
88
+ " - Start it: open the Ollama app, or run `ollama serve` in another terminal.\n"
89
+ " - Not installed? https://ollama.com/download\n"
90
+ " - Listening somewhere else? Set OLLAMA_HOST in your .env.local1."
91
+ )
92
+
93
+ models = available_ollama_models()
94
+ if not models:
95
+ return (
96
+ "Ollama is running but you have no models pulled.\n"
97
+ f" Pull one, e.g.: ollama pull {model_id}"
98
+ )
99
+
100
+ if model_id not in models:
101
+ return (
102
+ f"Ollama is running but the model '{model_id}' is not pulled.\n"
103
+ f" Pull it: ollama pull {model_id}\n"
104
+ f" Or pick one of: {', '.join(models)}\n"
105
+ " (the default comes from OLLAMA_MODEL_ID in your .env.local1)"
106
+ )
107
+
108
+ return None
109
+
110
+
111
+ def choose_model(default_model_id):
112
+ """Let the user pick from the locally-installed Ollama models at startup.
113
+
114
+ Falls back to ``default_model_id`` when input is non-interactive (tests /
115
+ CI / piped stdin), when Ollama lists no models, or when the user just
116
+ presses Enter.
117
+ """
118
+ models = available_ollama_models()
119
+ if not models or not sys.stdin.isatty():
120
+ return default_model_id
121
+
122
+ default_installed = default_model_id in models
123
+ fallback = default_model_id if default_installed else models[0]
124
+ if not default_installed:
125
+ # Don't silently redirect: tell the user their configured default
126
+ # isn't pulled, so the "(default)" marker below (which only marks the
127
+ # real configured default) isn't misleading.
128
+ print(
129
+ f"Configured default {default_model_id!r} is not installed; "
130
+ f"falling back to {fallback!r}."
131
+ )
132
+ print("Available Ollama models:")
133
+ for index, name in enumerate(models, start=1):
134
+ marker = " (default)" if name == default_model_id else ""
135
+ print(f" {index}. {name}{marker}")
136
+
137
+ try:
138
+ choice = input(f"Pick a model by number [default: {fallback}]: ").strip()
139
+ except (KeyboardInterrupt, EOFError):
140
+ return fallback
141
+ if not choice:
142
+ return fallback
143
+ if choice.isdigit() and 1 <= int(choice) <= len(models):
144
+ return models[int(choice) - 1]
145
+ print(f"'{choice}' is not a valid choice; using {fallback}.")
146
+ return fallback
147
+
148
+
149
+ def run_chat_loop(build_agent, title, examples, default_model_id=DEFAULT_OLLAMA_MODEL_ID):
150
+ """Run an agent as an interactive terminal chat, so we can test it by hand.
151
+
152
+ Checks Ollama is usable before starting, so a missing server or an unpulled
153
+ model is reported as something the user can act on rather than as a stack
154
+ trace on their first question.
155
+
156
+ Args:
157
+ build_agent: Callable taking a model id and returning the Agent to chat
158
+ with. Called after the user has picked the model at startup.
159
+ title: Shown at the top, e.g. "Ollama Contact Agent".
160
+ examples: Example questions printed as a hint.
161
+ default_model_id: The pre-selected model in the startup picker.
162
+ """
163
+ if not is_ollama_running():
164
+ print(f"\n{ollama_readiness_problem(default_model_id)}\n")
165
+ return
166
+
167
+ # Picked first: the user may choose a model other than the (possibly
168
+ # unpulled) default, and it is the chosen one that has to be usable.
169
+ selected_model_id = choose_model(default_model_id)
170
+
171
+ problem = ollama_readiness_problem(selected_model_id)
172
+ if problem:
173
+ print(f"\n{problem}\n")
174
+ return
175
+
176
+ agent = build_agent(selected_model_id)
177
+
178
+ print(f"\n{title} (local {selected_model_id})\n")
179
+ print("Ask, for example:")
180
+ for example in examples:
181
+ print(f" {example}")
182
+ print("Type 'exit' to quit.\n")
183
+
184
+ while True:
185
+ try:
186
+ user_input = input("> ").strip()
187
+ if not user_input:
188
+ continue
189
+ if user_input.lower() == "exit":
190
+ print("\nGoodbye!")
191
+ break
192
+ # The agent streams its answer to stdout itself, so we don't reprint it.
193
+ agent(user_input)
194
+ print()
195
+ except (KeyboardInterrupt, EOFError):
196
+ print("\nExiting...")
197
+ break
198
+ except Exception as error: # noqa: BLE001 - surface any error to the user
199
+ # Ollama can also go away mid-chat (laptop slept, app quit). Say so,
200
+ # instead of blaming the question the user just asked.
201
+ if not is_ollama_running():
202
+ print(f"\n{ollama_readiness_problem(selected_model_id)}\n")
203
+ break
204
+ print(f"\nError: {error}\nPlease try a different request.")