Skip to content

Commit

Permalink
updated to main
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelneale committed Sep 24, 2024
2 parents f42d885 + cb6a3d7 commit 7504678
Show file tree
Hide file tree
Showing 15 changed files with 377 additions and 35 deletions.
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ uv venv
uv run goose session start # or any of goose's commands (e.g. goose --help)
```

### Running from source

When you build from source you may want to run it from elsewhere.

1. Run `uv sync` as above
2. Run ```export goose_dev=`uv run which goose` ```
3. You can use that from anywhere in your system, for example `cd ~/ && $goose_dev session start`, or from your path if you like (advanced users only) to be running the latest.

## Developing goose-plugins

1. Clone the `goose-plugins` repo:
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ goose
<p align="center"><strong>goose</strong> <em>is a programming agent that runs on your machine.</em></p>

<p align="center">
<a href="https://opensource.org/licenses/Apache-2.0"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg"></a>
<a href="https://opensource.org/licenses/Apache-2.0"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg"></a>
<a href="https://square.github.io/goose/"><img src="https://img.shields.io/badge/goose_documentation-blue"></a>
</p>

<p align="center">
Expand Down Expand Up @@ -101,14 +102,16 @@ default:
You can edit this configuration file to use different LLMs and toolkits in `goose`. `goose can also be extended to support any LLM or combination of LLMs

#### provider
Provider of LLM. LLM providers that currently are supported by `goose`:
Provider of LLM. LLM providers that currently are supported by `goose` (more can be supported by plugins):

| Provider | Required environment variable(s) to access provider |
| :----- | :------------------------------ |
| openai | `OPENAI_API_KEY` |
| anthropic | `ANTHROPIC_API_KEY` |
| databricks | `DATABRICKS_HOST` and `DATABRICKS_TOKEN` |
| ollama * | `OLLAMA_HOST` and ollama running |

* ollama is for local LLMs, and is limited by the tool calling model you can choose and run on local hardware, considered experimental.

#### processor
Model for complex, multi-step tasks such as writing code and executing commands. Example: `gpt-4o`. You should choose the model based the provider you configured.
Expand Down
25 changes: 23 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# This is the default recipe when no arguments are provided
[private]
default:
@just --list --unsorted

Expand All @@ -20,3 +18,26 @@ coverage *FLAGS:

docs:
cd docs && uv sync && uv run mkdocs serve

ai-exchange-version:
curl -s https://pypi.org/pypi/ai-exchange/json | jq -r .info.version

# bump project version, push, create pr
release version:
uvx --from=toml-cli toml set --toml-path=pyproject.toml project.version {{version}}
ai_exchange_version=$(just ai-exchange-version) && sed -i '' 's/ai-exchange>=.*/ai-exchange>='"${ai_exchange_version}"'\",/' pyproject.toml
git checkout -b release-version-{{version}}
git add pyproject.toml
git commit -m "chore(release): release version {{version}}"

tag_version:
grep 'version' pyproject.toml | cut -d '"' -f 2

tag:
git tag v$(just tag_version)

# this will kick of ci for release
# use this when release branch is merged to main
tag-push:
just tag
git push origin tag v$(just tag_version)
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ dependencies = [
"attrs>=23.2.0",
"rich>=13.7.1",
"ruamel-yaml>=0.18.6",
"ai-exchange>=0.9.1",
"ai-exchange>=0.9.2",
"click>=8.1.7",
"prompt-toolkit>=3.0.47",
]
Expand Down Expand Up @@ -63,5 +63,3 @@ dev-dependencies = [
"mkdocs-include-markdown-plugin>=6.2.2",
"mkdocs-callouts>=1.14.0",
]


19 changes: 19 additions & 0 deletions src/goose/_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import logging
from pathlib import Path

_LOGGER_NAME = "goose"
_LOGGER_FILE_NAME = "goose.log"


def setup_logging(log_file_directory: Path, log_level: str = "INFO") -> None:
logger = logging.getLogger(_LOGGER_NAME)
logger.setLevel(getattr(logging, log_level))
log_file_directory.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_file_directory / _LOGGER_FILE_NAME)
logger.addHandler(file_handler)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
file_handler.setFormatter(formatter)


def get_logger() -> logging.Logger:
return logging.getLogger(_LOGGER_NAME)
1 change: 1 addition & 0 deletions src/goose/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
PROFILES_CONFIG_PATH = GOOSE_GLOBAL_PATH.joinpath("profiles.yaml")
SESSIONS_PATH = GOOSE_GLOBAL_PATH.joinpath("sessions")
SESSION_FILE_SUFFIX = ".jsonl"
LOG_PATH = GOOSE_GLOBAL_PATH.joinpath("logs")


@cache
Expand Down
26 changes: 24 additions & 2 deletions src/goose/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from goose.cli.config import SESSIONS_PATH
from goose.cli.session import Session
from goose.toolkit.utils import render_template, parse_plan
from goose.utils import load_plugins
from goose.utils.session_file import list_sorted_session_files

Expand Down Expand Up @@ -65,16 +66,37 @@ def list_toolkits() -> None:
@session.command(name="start")
@click.option("--profile")
@click.option("--plan", type=click.Path(exists=True))
def session_start(profile: str, plan: Optional[str] = None) -> None:
@click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="INFO")
def session_start(profile: str, log_level: str, plan: Optional[str] = None) -> None:
"""Start a new goose session"""
if plan:
yaml = YAML()
with open(plan, "r") as f:
_plan = yaml.load(f)
else:
_plan = None
session = Session(profile=profile, plan=_plan, log_level=log_level)
session.run()


def parse_args(ctx: click.Context, param: click.Parameter, value: str) -> dict[str, str]:
if not value:
return {}
args = {}
for item in value.split(","):
key, val = item.split(":")
args[key.strip()] = val.strip()

session = Session(profile=profile, plan=_plan)
return args


@session.command(name="planned")
@click.option("--plan", type=click.Path(exists=True))
@click.option("-a", "--args", callback=parse_args, help="Args in the format arg1:value1,arg2:value2")
def session_planned(plan: str, args: Optional[dict[str, str]]) -> None:
plan_templated = render_template(Path(plan), context=args)
_plan = parse_plan(plan_templated)
session = Session(plan=_plan)
session.run()


Expand Down
16 changes: 10 additions & 6 deletions src/goose/cli/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,13 @@
from rich.status import Status

from goose.build import build_exchange
from goose.cli.config import (
default_profiles,
ensure_config,
read_config,
session_path,
)
from goose.cli.config import default_profiles, ensure_config, read_config, session_path, LOG_PATH
from goose._logger import get_logger, setup_logging
from goose.cli.prompt.goose_prompt_session import GoosePromptSession
from goose.notifier import Notifier
from goose.profile import Profile
from goose.utils import droid, load_plugins
from goose.utils._cost_calculator import get_total_cost_message
from goose.utils.session_file import read_from_file, write_to_file

RESUME_MESSAGE = "I see we were interrupted. How can I help you?"
Expand Down Expand Up @@ -90,13 +87,15 @@ def __init__(
name: Optional[str] = None,
profile: Optional[str] = None,
plan: Optional[dict] = None,
log_level: Optional[str] = "INFO",
**kwargs: Dict[str, Any],
) -> None:
self.name = name
self.status_indicator = Status("", spinner="dots")
self.notifier = SessionNotifier(self.status_indicator)

self.exchange = build_exchange(profile=load_profile(profile), notifier=self.notifier)
setup_logging(log_file_directory=LOG_PATH, log_level=log_level)

if name is not None and self.session_file_path.exists():
messages = self.load_session()
Expand Down Expand Up @@ -173,6 +172,7 @@ def run(self) -> None:
message = Message.user(text=user_input.text) if user_input.to_continue() else None

self.save_session()
self._log_cost()

def reply(self) -> None:
"""Reply to the last user message, calling tools as needed
Expand Down Expand Up @@ -256,6 +256,10 @@ def generate_session_name(self) -> None:
self.name = user_entered_session_name if user_entered_session_name else droid()
print(f"Saving to [bold cyan]{self.session_file_path}[/bold cyan]")

def _log_cost(self) -> None:
get_logger().info(get_total_cost_message(self.exchange.get_token_usage()))
print("You can view the cost and token usage in the log directory", LOG_PATH)


if __name__ == "__main__":
session = Session()
105 changes: 87 additions & 18 deletions src/goose/toolkit/developer.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
from pathlib import Path
from subprocess import CompletedProcess, run
from typing import List, Dict
import os
from goose.utils.check_shell_command import is_dangerous_command
import re
import subprocess
import time
from pathlib import Path
from typing import Dict, List

from exchange import Message
from goose.toolkit.base import Toolkit, tool
from goose.toolkit.utils import get_language, render_template
from goose.utils.ask import ask_an_ai
from goose.utils.check_shell_command import is_dangerous_command
from rich import box
from rich.markdown import Markdown
from rich.panel import Panel
from rich.prompt import Confirm
from rich.table import Table
from rich.text import Text

from goose.toolkit.base import Toolkit, tool
from goose.toolkit.utils import get_language, render_template


def keep_unsafe_command_prompt(command: str) -> bool:
command_text = Text(command, style="bold red")
Expand Down Expand Up @@ -136,7 +138,7 @@ def read_file(self, path: str) -> str:
@tool
def shell(self, command: str) -> str:
"""
Execute a command on the shell (in OSX)
Execute a command on the shell
This will return the output and error concatenated into a single string, as
you would see from running on the command line. There will also be an indication
Expand All @@ -146,11 +148,7 @@ def shell(self, command: str) -> str:
command (str): The shell command to run. It can support multiline statements
if you need to run more than one at a time
"""
self.notifier.status("planning to run shell command")
# Log the command being executed in a visually structured format (Markdown).
# The `.log` method is used here to log the command execution in the application's UX
# this method is dynamically attached to functions in the Goose framework to handle user-visible
# logging and integrates with the overall UI logging system
self.notifier.log(Panel.fit(Markdown(f"```bash\n{command}\n```"), title="shell"))

if is_dangerous_command(command):
Expand All @@ -159,16 +157,87 @@ def shell(self, command: str) -> str:
if not keep_unsafe_command_prompt(command):
raise RuntimeError(
f"The command {command} was rejected as dangerous by the user."
+ " Do not proceed further, instead ask for instructions."
" Do not proceed further, instead ask for instructions."
)
self.notifier.start()
self.notifier.status("running shell command")
result: CompletedProcess = run(command, shell=True, text=True, capture_output=True, check=False)
if result.returncode == 0:
output = "Command succeeded"

# Define patterns that might indicate the process is waiting for input
interaction_patterns = [
r"Do you want to", # Common prompt phrase
r"Enter password", # Password prompt
r"Are you sure", # Confirmation prompt
r"\(y/N\)", # Yes/No prompt
r"Press any key to continue", # Awaiting keypress
r"Waiting for input", # General waiting message
r"\?\s", # Prompts starting with '? '
]
compiled_patterns = [re.compile(pattern, re.IGNORECASE) for pattern in interaction_patterns]

proc = subprocess.Popen(
command,
shell=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
# this enables us to read lines without blocking
os.set_blocking(proc.stdout.fileno(), False)

# Accumulate the output logs while checking if it might be blocked
output_lines = []
last_output_time = time.time()
cutoff = 10
while proc.poll() is None:
self.notifier.status("running shell command")
line = proc.stdout.readline()
if line:
output_lines.append(line)
last_output_time = time.time()

# If we see a clear pattern match, we plan to abort
exit_criteria = any(pattern.search(line) for pattern in compiled_patterns)

# and if we haven't seen a new line in 10+s, check with AI to see if it may be stuck
if not exit_criteria and time.time() - last_output_time > cutoff:
self.notifier.status("checking on shell status")
response = ask_an_ai(
input="\n".join([command] + output_lines),
prompt=(
"You will evaluate the output of shell commands to see if they may be stuck."
" Look for commands that appear to be awaiting user input, or otherwise running indefinitely (such as a web service)." # noqa
" A command that will take a while, such as downloading resources is okay." # noqa
" return [Yes] if stuck, [No] otherwise."
),
exchange=self.exchange_view.processor,
with_tools=False,
)
exit_criteria = "[yes]" in response.content[0].text.lower()
# We add exponential backoff for how often we check for the command being stuck
cutoff *= 10

if exit_criteria:
proc.terminate()
raise ValueError(
f"The command `{command}` looks like it will run indefinitely or is otherwise stuck."
f"You may be able to specify inputs if it applies to this command."
f"Otherwise to enable continued iteration, you'll need to ask the user to run this command in another terminal." # noqa
)

# read any remaining lines
while line := proc.stdout.readline():
output_lines.append(line)
output = "".join(output_lines)

# Determine the result based on the return code
if proc.returncode == 0:
result = "Command succeeded"
else:
output = f"Command failed with returncode {result.returncode}"
return "\n".join([output, result.stdout, result.stderr])
result = f"Command failed with returncode {proc.returncode}"

# Return the combined result and outputs if we made it this far
return "\n".join([result, output])

@tool
def write_file(self, path: str, content: str) -> str:
Expand Down
Loading

0 comments on commit 7504678

Please sign in to comment.