Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/poly/cli_commands/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@ def push(
"message": output,
"dry_run": dry_run,
}
if project.push_warnings:
json_output["warnings"] = project.push_warnings
if new_branch_name:
json_output["switched_to"] = new_branch_name
json_output["new_branch_id"] = project.branch_id
Expand Down Expand Up @@ -436,6 +438,8 @@ def push(

if new_branch_name:
warning(f"Created and switched to new branch '{new_branch_name}'.")
for push_warning in project.push_warnings or []:
warning(push_warning)
if push_ok:
success(f"Pushed {project.account_id}/{project.project_id} to Agent Studio.")
else:
Expand Down
27 changes: 24 additions & 3 deletions src/poly/docs/agent_settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,38 @@

## Overview

Agent settings define the agent's identity and behavioral rules. They live in `agent_settings/` and consist of three resources: personality, role, and rules.
Agent settings define the agent's identity and behavioral rules. They live in `agent_settings/` and consist of the persona and rules, plus the superseded personality and role settings.

## File structure
```
agent_settings/
├── personality.yaml
├── role.yaml
├── persona.txt # The agent's identity
├── personality.yaml # Superseded by persona.txt
├── role.yaml # Superseded by persona.txt
├── rules.txt
└── experimental_config.json # See experimental_config docs
```

## Persona (`persona.txt`)

Free-text description of who the agent is, and the single field that defines the agent's identity. This is what the **Role** field in Agent Studio edits — it replaces `personality.yaml` and `role.yaml`, which are no longer surfaced to builders.

### Supported references
- `{{vrbl:variable_name}}` — variables. No other reference type is allowed.

### Example
```text
You are a calm and polite concierge for {{vrbl:hotel_name}}. Keep answers short.
```

### Notes
- For projects that predate the persona and have never authored one, the pulled content is **derived** from `personality.yaml` and `role.yaml`. Nothing is stored server-side until someone edits it, and `poly push` sends nothing while the file is untouched.
- Editing `persona.txt` and pushing authors a real persona. From that point the content is fixed and no longer tracks `personality.yaml` / `role.yaml`.

## Personality (`personality.yaml`)

**Superseded by `persona.txt`.** Kept for projects that predate the persona; it still pulls and pushes, but no longer affects the agent's identity. `poly push` warns when you change it on a project that has a persona.

Controls the agent's conversational tone.

### Fields
Expand All @@ -32,6 +51,8 @@ custom: ""

## Role (`role.yaml`)

**Superseded by `persona.txt`.** Kept for projects that predate the persona; it still pulls and pushes, but no longer affects the agent's identity. `poly push` warns when you change it on a project that has a persona.

Defines what the agent is (its job title / purpose).

### Fields
Expand Down
3 changes: 2 additions & 1 deletion src/poly/docs/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Each project defines an AI voice or webchat agent. Resources in the project (flo
├── _gen/ # Generated stubs - do not edit
├── agent_settings/ # Agent identity and behavior
│ ├── languages.yaml # Optional
│ ├── persona.txt # The Agent Studio "Role" field
│ ├── personality.yaml
│ ├── role.yaml
│ ├── rules.txt
Expand Down Expand Up @@ -117,7 +118,7 @@ Resource-specific documentation is available via `poly docs {resource} [resource

| Name | Description |
|------|-------------|
| `agent_settings` | Personality, role, rules |
| `agent_settings` | Persona, rules |
| `api_integrations` | External HTTP API definitions |
| `chat_settings` | Chat greeting, style prompt |
| `context` | Context files for agent knowledge |
Expand Down
24 changes: 24 additions & 0 deletions src/poly/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
Pronunciation,
Resource,
ResourceMapping,
SettingsPersona,
SettingsPersonality,
SettingsRole,
SubResource,
TestCase,
Topic,
Expand Down Expand Up @@ -121,6 +124,8 @@ class AgentStudioProject:
# before they are saved.
_not_loaded_resources: list[ResourceType] = None

push_warnings: list[str] = None

@property
def all_resources(self) -> list[Resource]:
"""Get all resources in the project"""
Expand Down Expand Up @@ -1199,6 +1204,8 @@ def push_project(
- List of commands serialized to protobuf.
"""

self.push_warnings = []

if not dry_run:
# If force, load latest version of the project
# to compare against
Expand Down Expand Up @@ -1287,6 +1294,19 @@ def push_project(
updated_resources.update(subresource_changes.updated)
deleted_resources.update(subresource_changes.deleted)

if new_state.get(SettingsPersona):
shadowed = [
resource.file_path
for resource_type in (SettingsPersonality, SettingsRole)
for resource in updated_resources.get(resource_type, {}).values()
]
if shadowed:
self.push_warnings.append(
f"{', '.join(sorted(shadowed))} changed, but this project uses "
f"{next(iter(new_state[SettingsPersona].values())).file_path} — "
"personality and role no longer affect the Role field in Agent Studio."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should just deprecate, so not even read them

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed as well.


if not (updated_resources or new_resources or deleted_resources):
return False, "No changes detected", []

Expand Down Expand Up @@ -1533,6 +1553,10 @@ def _clean_resources_before_push(
queue_command=lambda command: self.api_handler.queue_command(command),
)

# There is no create_persona command; authoring a persona is an update.
if new_personas := new_resources.pop(SettingsPersona, None):
updated_resources.setdefault(SettingsPersona, {}).update(new_personas)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed? pre push we do a pull, that should mean it exists

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.


return PushPhaseChangeSet(
main=ResourceChangeSet(
new=new_resources,
Expand Down
1 change: 1 addition & 0 deletions src/poly/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copyright PolyAI Limited
from poly.resources.agent_settings import (
SettingsPersona,
SettingsPersonality,
SettingsRole,
SettingsRules,
Expand Down
122 changes: 122 additions & 0 deletions src/poly/resources/agent_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@

import poly.resources.resource_utils as utils
from poly.handlers.protobuf.agent_settings_pb2 import (
Persona_UpdatePersona,
Personality_UpdatePersonality,
PersonaReferences,
Role_UpdateRole,
Rules_UpdateRules,
)
Expand All @@ -30,6 +32,7 @@
"variables",
"translations",
]
ALLOWED_PERSONA_REFERENCES = ["variables"]
ALLOWED_ADJECTIVES = {
"Polite",
"Calm",
Expand Down Expand Up @@ -277,6 +280,125 @@ def discover_resources(base_path: str) -> list[str]:
return [file_path]


@register_resource("persona")
@dataclass
class SettingsPersona(Resource):
"""Resource class for managing the persona setting.

A single free-text description of who the agent is, edited as the "Role"
field in Agent Studio. It replaces the personality and role settings, which
remain on the wire but are no longer surfaced to builders.
"""

content: str

@cached_property
def file_path(self) -> str:
"""Get the file path for the Persona resource."""
return os.path.join("agent_settings", "persona.txt")

@property
def raw(self) -> str:
"""Convert the resource to a raw format."""
return self.content

@staticmethod
def make_pretty(
contents: str, resource_mappings: list[ResourceMapping] = None, **kwargs
) -> str:
"""Replace resource IDs with resource names in the provided contents."""
return utils.replace_resource_ids_with_names(contents, resource_mappings or [])

@classmethod
def from_pretty(
cls, contents: str, resource_mappings: list[ResourceMapping] = None, **kwargs
) -> str:
"""Replace resource names with resource IDs in the provided contents."""
return utils.replace_resource_names_with_ids(contents, resource_mappings or [])

def validate(self, resource_mappings: list[ResourceMapping] = None, **kwargs) -> None:
"""Validate the persona resource."""
references = utils.get_references_from_prompt(
self.content, ALLOWED_PERSONA_REFERENCES, raise_on_invalid=True
)
valid, invalid_references = utils.validate_references(references, resource_mappings)
if not valid:
raise ValueError(f"Invalid references: {invalid_references}")

@classmethod
def from_projection(cls, projection: dict) -> dict[str, "SettingsPersona"]:
"""Parse the persona setting from a projection dict.

The projection carries a persona object whether or not any content was
ever authored, so read the content rather than the object: absent content
means there is nothing to write to disk, not that anything is wrong.
"""
agent_settings = projection.get("agentSettings", {})
persona = agent_settings.get("persona") or {}
content = persona.get("content")
if content is None:
return {}
return {
"persona": cls(
resource_id="persona",
name="persona",
content=content,
)
}

@classmethod
def read_local_resource(
cls, file_path: str, resource_id: str, resource_name: str, **kwargs
) -> "SettingsPersona":
"""Read a local plain text resource from the given file path."""
content = cls.read_to_raw(file_path, **kwargs)
return SettingsPersona(
resource_id=resource_id,
name=resource_name,
content=content,
)

def build_update_proto(self) -> Persona_UpdatePersona:
"""Create a proto for updating the resource."""

references = utils.get_references_from_prompt(self.content, ALLOWED_PERSONA_REFERENCES)

return Persona_UpdatePersona(
content=self.content,
references=PersonaReferences(variables=references.get("variables", {})),
)

def build_create_proto(self) -> Message:
"""Create a proto for creating the resource."""
raise NotImplementedError("Create operation not supported for Persona settings.")

def build_delete_proto(self) -> Message:
"""Create a proto for deleting the resource."""
raise NotImplementedError("Delete operation not supported for Persona settings.")

@property
def command_type(self) -> str:
"""Get the update type for updating the resource."""
return "persona"

@staticmethod
def discover_resources(base_path: str) -> list[str]:
"""Discover resources of this type in the given base path.

Args:
base_path (str): The base path to search for resources.

Returns:
list[str]: A list of file paths of discovered resources.
"""
file_path = os.path.join(base_path, "agent_settings", "persona.txt")

if not os.path.exists(file_path):
return []

return [file_path]


@register_resource("rules")
@dataclass
class SettingsRules(Resource):
Expand Down
42 changes: 42 additions & 0 deletions src/poly/tests/project_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
Pronunciation,
Resource,
ResourceMapping,
SettingsPersona,
SettingsPersonality,
SettingsRole,
SettingsRules,
Expand Down Expand Up @@ -311,6 +312,10 @@ def test_discover_local_resources(self):
local_resources[SettingsRules],
[os.path.join(TEST_DIR, "agent_settings", "rules.txt")],
)
self.assertEqual(
local_resources[SettingsPersona],
[os.path.join(TEST_DIR, "agent_settings", "persona.txt")],
)

# Finds all Functions and Flow Steps
self.assertEqual(len(local_resources[Function]), 13)
Expand Down Expand Up @@ -2481,6 +2486,43 @@ def test_push_project_dry_run(self):
self.mock_api_handler.clear_command_queue.assert_called_once()


def test_push_project_new_persona_is_pushed_as_an_update(self):
project_data = deepcopy(PROJECT_DATA)
project_data["resources"].pop("persona")
project = AgentStudioProject.from_dict(project_data, TEST_DIR)

success, _, _ = project.push_project(force=True)

self.assertTrue(success)
call_args = self.mock_api_handler.queue_resources.call_args
self.assertNotIn(SettingsPersona, call_args.kwargs["new_resources"])
personas = call_args.kwargs["updated_resources"][SettingsPersona]
self.assertEqual([r.name for r in personas.values()], ["persona"])

def test_push_project_warns_when_legacy_settings_are_shadowed(self):
project_data = deepcopy(PROJECT_DATA)
project_data["resources"]["personality"]["PERSONALITY-personality"]["custom"] = "stale"
project = AgentStudioProject.from_dict(project_data, TEST_DIR)

success, _, _ = project.push_project(force=True)

self.assertTrue(success)
self.assertEqual(len(project.push_warnings), 1)
self.assertIn("personality.yaml", project.push_warnings[0])
self.assertIn("persona.txt", project.push_warnings[0])

def test_push_project_does_not_warn_when_legacy_settings_are_untouched(self):
project_data = deepcopy(PROJECT_DATA)
project_data["resources"]["topics"].pop("TOPIC-Topic 1")
project = AgentStudioProject.from_dict(project_data, TEST_DIR)

success, _, _ = project.push_project(force=True)

self.assertTrue(success)
self.assertEqual(project.push_warnings, [])



class ValidateProjectTest(unittest.TestCase):
"""Tests for the validate_project method"""

Expand Down
Loading
Loading